mvc-kit 2.13.0 → 2.13.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 (218) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
  3. package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
  4. package/agent-config/lib/install-claude.mjs +39 -110
  5. package/examples/primitive/channel.ts +109 -0
  6. package/examples/primitive/collection.ts +118 -0
  7. package/examples/primitive/controller.ts +118 -0
  8. package/examples/primitive/counter.ts +108 -0
  9. package/examples/primitive/env.d.ts +1 -0
  10. package/examples/primitive/eventbus.ts +77 -0
  11. package/examples/primitive/feed.ts +162 -0
  12. package/examples/primitive/model.ts +82 -0
  13. package/examples/primitive/pagination.ts +91 -0
  14. package/examples/primitive/pending.ts +189 -0
  15. package/examples/primitive/persistent-collection.ts +116 -0
  16. package/examples/primitive/resource.ts +114 -0
  17. package/examples/primitive/selection.ts +96 -0
  18. package/examples/primitive/sorting.ts +112 -0
  19. package/examples/primitive/timer.ts +58 -0
  20. package/examples/primitive/trackable.ts +225 -0
  21. package/examples/primitive/tsconfig.json +20 -0
  22. package/examples/primitive/viewmodel-service.ts +161 -0
  23. package/examples/react/AuthExample/index.html +12 -0
  24. package/examples/react/AuthExample/src/App.tsx +29 -0
  25. package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
  26. package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
  27. package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
  28. package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
  29. package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
  30. package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
  31. package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
  32. package/examples/react/AuthExample/src/env.d.ts +10 -0
  33. package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
  34. package/examples/react/AuthExample/src/main.tsx +10 -0
  35. package/examples/react/AuthExample/src/mock/api.ts +78 -0
  36. package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
  37. package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
  38. package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
  39. package/examples/react/AuthExample/src/styles.css +445 -0
  40. package/examples/react/AuthExample/src/types/auth.ts +12 -0
  41. package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
  42. package/examples/react/AuthExample/tsconfig.json +22 -0
  43. package/examples/react/AuthExample/vite.config.ts +18 -0
  44. package/examples/react/ComplexApp/index.html +12 -0
  45. package/examples/react/ComplexApp/src/App.tsx +17 -0
  46. package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
  47. package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
  48. package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
  49. package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
  50. package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
  51. package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
  52. package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
  53. package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
  54. package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
  55. package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
  56. package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
  57. package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
  58. package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
  59. package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
  60. package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
  61. package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
  62. package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
  63. package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
  64. package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
  65. package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
  66. package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
  67. package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
  68. package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
  69. package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
  70. package/examples/react/ComplexApp/src/env.d.ts +10 -0
  71. package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
  72. package/examples/react/ComplexApp/src/main.tsx +10 -0
  73. package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
  74. package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
  75. package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
  76. package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
  77. package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
  78. package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
  79. package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
  80. package/examples/react/ComplexApp/src/styles.css +463 -0
  81. package/examples/react/ComplexApp/src/types/activity.ts +8 -0
  82. package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
  83. package/examples/react/ComplexApp/src/types/social.ts +8 -0
  84. package/examples/react/ComplexApp/src/types/users.ts +6 -0
  85. package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
  86. package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
  87. package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
  88. package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
  89. package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
  90. package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
  91. package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
  92. package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
  93. package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
  94. package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
  95. package/examples/react/ComplexApp/tsconfig.json +22 -0
  96. package/examples/react/ComplexApp/vite.config.ts +18 -0
  97. package/examples/react/FullApp/index.html +12 -0
  98. package/examples/react/FullApp/src/App.tsx +28 -0
  99. package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
  100. package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
  101. package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
  102. package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
  103. package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
  104. package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
  105. package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
  106. package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
  107. package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
  108. package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
  109. package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
  110. package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
  111. package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
  112. package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
  113. package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
  114. package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
  115. package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
  116. package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
  117. package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
  118. package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
  119. package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
  120. package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
  121. package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
  122. package/examples/react/FullApp/src/env.d.ts +10 -0
  123. package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
  124. package/examples/react/FullApp/src/main.tsx +10 -0
  125. package/examples/react/FullApp/src/mock/delay.ts +21 -0
  126. package/examples/react/FullApp/src/mock/locations.ts +76 -0
  127. package/examples/react/FullApp/src/mock/messages.ts +237 -0
  128. package/examples/react/FullApp/src/mock/users.ts +84 -0
  129. package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
  130. package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
  131. package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
  132. package/examples/react/FullApp/src/services/AuthService.ts +18 -0
  133. package/examples/react/FullApp/src/services/LocationService.ts +23 -0
  134. package/examples/react/FullApp/src/services/MessageService.ts +65 -0
  135. package/examples/react/FullApp/src/services/UserService.ts +23 -0
  136. package/examples/react/FullApp/src/styles.css +767 -0
  137. package/examples/react/FullApp/src/types/conversation.ts +7 -0
  138. package/examples/react/FullApp/src/types/location.ts +12 -0
  139. package/examples/react/FullApp/src/types/message.ts +7 -0
  140. package/examples/react/FullApp/src/types/user.ts +10 -0
  141. package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
  142. package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
  143. package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
  144. package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
  145. package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
  146. package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
  147. package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
  148. package/examples/react/FullApp/tsconfig.json +22 -0
  149. package/examples/react/FullApp/vite.config.ts +18 -0
  150. package/examples/react/WorkerApp/index.html +12 -0
  151. package/examples/react/WorkerApp/src/App.tsx +24 -0
  152. package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
  153. package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
  154. package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
  155. package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
  156. package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
  157. package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
  158. package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
  159. package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
  160. package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
  161. package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
  162. package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
  163. package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
  164. package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
  165. package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
  166. package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
  167. package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
  168. package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
  169. package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
  170. package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
  171. package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
  172. package/examples/react/WorkerApp/src/env.d.ts +10 -0
  173. package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
  174. package/examples/react/WorkerApp/src/main.tsx +10 -0
  175. package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
  176. package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
  177. package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
  178. package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
  179. package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
  180. package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
  181. package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
  182. package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
  183. package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
  184. package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
  185. package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
  186. package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
  187. package/examples/react/WorkerApp/src/styles.css +756 -0
  188. package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
  189. package/examples/react/WorkerApp/src/types/message.ts +7 -0
  190. package/examples/react/WorkerApp/src/types/shift.ts +13 -0
  191. package/examples/react/WorkerApp/src/types/site.ts +8 -0
  192. package/examples/react/WorkerApp/src/types/worker.ts +8 -0
  193. package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
  194. package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
  195. package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
  196. package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
  197. package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
  198. package/examples/react/WorkerApp/tsconfig.json +22 -0
  199. package/examples/react/WorkerApp/vite.config.ts +18 -0
  200. package/package.json +4 -2
  201. /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
  202. /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
  203. /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
  204. /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
  205. /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
  206. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
  207. /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
  208. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
  209. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
  210. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
  211. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
  212. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
  213. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
  214. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
  215. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
  216. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
  217. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
  218. /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
@@ -0,0 +1,189 @@
1
+ import { Pending, Resource, singleton, teardownAll } from 'mvc-kit';
2
+
3
+ // Pending: Per-item operation queue with retry and status tracking
4
+ //
5
+ // A subscribable helper that manages async operations per item ID,
6
+ // with automatic exponential backoff retries for transient errors.
7
+ // Designed to live on a singleton Resource (NOT on a ViewModel).
8
+ //
9
+ // Typical use case: sending messages, submitting forms, processing
10
+ // items where each operation needs individual retry/cancel controls.
11
+
12
+ // --- Entity types ---
13
+
14
+ interface Message {
15
+ id: string;
16
+ text: string;
17
+ senderId: string;
18
+ }
19
+
20
+ // --- Resource with Pending ---
21
+
22
+ class MessagesResource extends Resource<Message> {
23
+ // Pending lives on the Resource so it survives ViewModel unmounts
24
+ readonly sending = new Pending<string>();
25
+
26
+ async send(message: Message) {
27
+ // enqueue: fire-and-forget with automatic retry on failure
28
+ this.sending.enqueue(
29
+ message.id, // unique key for this operation
30
+ 'send', // operation name (for display/debugging)
31
+ async () => { // the execute callback
32
+ await fakeSend(message);
33
+ this.add(message); // add to collection on success
34
+ },
35
+ );
36
+ }
37
+
38
+ protected onDispose() {
39
+ this.sending.dispose();
40
+ }
41
+ }
42
+
43
+ // --- Basic Pending usage (standalone) ---
44
+
45
+ const pending = new Pending<string>();
46
+
47
+ // Subscribe to status changes
48
+ pending.subscribe(() => {
49
+ console.log(`Pending: ${pending.count} operations, failed=${pending.failedCount}`);
50
+ });
51
+
52
+ // Enqueue an operation
53
+ pending.enqueue(
54
+ 'task-1',
55
+ 'upload',
56
+ async () => {
57
+ // Simulated async work
58
+ await delay(100);
59
+ console.log('Upload complete!');
60
+ },
61
+ );
62
+
63
+ console.log('Has pending:', pending.hasPending); // true
64
+
65
+ // Check individual status
66
+ const status = pending.getStatus('task-1');
67
+ console.log('Status:', status?.status); // 'active'
68
+ console.log('Attempts:', status?.attempts); // 1
69
+
70
+ // Wait for completion
71
+ await delay(200);
72
+ console.log('After completion count:', pending.count); // 0 (removed on success)
73
+
74
+ // --- Retry behavior ---
75
+
76
+ let failCount = 0;
77
+ const retryPending = new Pending<string>();
78
+
79
+ retryPending.subscribe(() => {
80
+ const entry = retryPending.getStatus('flaky-op');
81
+ if (entry) {
82
+ console.log(`Flaky op: status=${entry.status}, attempts=${entry.attempts}`);
83
+ }
84
+ });
85
+
86
+ retryPending.enqueue(
87
+ 'flaky-op',
88
+ 'sync',
89
+ async () => {
90
+ failCount++;
91
+ if (failCount < 3) {
92
+ // Transient errors (network, timeout, server_error) trigger retries
93
+ const err = new Error('Connection failed');
94
+ (err as any).code = 'network';
95
+ throw err;
96
+ }
97
+ console.log('Flaky operation succeeded on attempt', failCount);
98
+ },
99
+ );
100
+
101
+ // Wait for retries to complete
102
+ await delay(5000);
103
+
104
+ // --- Manual controls ---
105
+
106
+ const controlPending = new Pending<string>();
107
+
108
+ // Enqueue a long-running operation
109
+ controlPending.enqueue(
110
+ 'slow-task',
111
+ 'process',
112
+ async () => {
113
+ await delay(10000); // very slow
114
+ },
115
+ );
116
+
117
+ // Cancel an in-flight operation
118
+ controlPending.cancel('slow-task');
119
+ console.log('After cancel:', controlPending.has('slow-task')); // false
120
+
121
+ // Enqueue one that will fail
122
+ controlPending.enqueue(
123
+ 'will-fail',
124
+ 'doomed',
125
+ async () => {
126
+ const err = new Error('Fatal error');
127
+ (err as any).code = 'validation'; // non-retryable error code
128
+ throw err;
129
+ },
130
+ );
131
+
132
+ await delay(200);
133
+
134
+ console.log('Has failed:', controlPending.hasFailed); // true
135
+ console.log('Failed count:', controlPending.failedCount); // 1
136
+
137
+ // Dismiss a failed operation (remove without retrying)
138
+ controlPending.dismiss('will-fail');
139
+ console.log('After dismiss:', controlPending.count); // 0
140
+
141
+ // --- Entries (iterate all operations) ---
142
+
143
+ const listPending = new Pending<string>();
144
+
145
+ listPending.enqueue('a', 'upload', () => delay(500));
146
+ listPending.enqueue('b', 'upload', () => delay(500));
147
+ listPending.enqueue('c', 'upload', () => delay(500));
148
+
149
+ console.log('All entries:', listPending.entries.map(e => e.id)); // ['a', 'b', 'c']
150
+
151
+ // Cancel all at once
152
+ listPending.cancelAll();
153
+ console.log('After cancelAll:', listPending.count); // 0
154
+
155
+ // --- Resource integration pattern ---
156
+
157
+ const resource = singleton(MessagesResource);
158
+ await resource.init();
159
+
160
+ // Send messages — each gets independent retry/cancel
161
+ resource.send({ id: 'msg-1', text: 'Hello!', senderId: 'user-1' });
162
+ resource.send({ id: 'msg-2', text: 'How are you?', senderId: 'user-1' });
163
+
164
+ console.log('Sending count:', resource.sending.count); // 2
165
+ console.log('msg-1 status:', resource.sending.getStatus('msg-1')?.status); // 'active'
166
+
167
+ await delay(200);
168
+
169
+ console.log('Messages in collection:', resource.length); // 2 (both sent)
170
+ console.log('Sending count after:', resource.sending.count); // 0
171
+
172
+ // Cleanup
173
+ pending.dispose();
174
+ retryPending.dispose();
175
+ controlPending.dispose();
176
+ listPending.dispose();
177
+ resource.dispose();
178
+ teardownAll();
179
+
180
+ // --- Helpers ---
181
+
182
+ function delay(ms: number): Promise<void> {
183
+ return new Promise(resolve => setTimeout(resolve, ms));
184
+ }
185
+
186
+ async function fakeSend(message: Message): Promise<void> {
187
+ await delay(100);
188
+ console.log(`Sent: "${message.text}"`);
189
+ }
@@ -0,0 +1,116 @@
1
+ import { PersistentCollection } from 'mvc-kit';
2
+
3
+ // PersistentCollection: Collection + automatic storage persistence
4
+ //
5
+ // Abstract base class that extends Collection with delta tracking,
6
+ // debounced writes, and hydration. Subclass it and implement the
7
+ // persist* methods to connect to your storage backend.
8
+ //
9
+ // Concrete adapters are provided for common platforms:
10
+ // - WebStorageCollection (mvc-kit/web) — localStorage/sessionStorage
11
+ // - IndexedDBCollection (mvc-kit/web) — IndexedDB per-item storage
12
+ // - NativeCollection (mvc-kit/react-native) — configurable backend
13
+ //
14
+ // This example shows the abstract contract. See the platform-specific
15
+ // adapters for ready-to-use implementations.
16
+
17
+ // --- Entity type ---
18
+
19
+ interface CartItem {
20
+ id: string;
21
+ name: string;
22
+ quantity: number;
23
+ price: number;
24
+ }
25
+
26
+ // --- Custom PersistentCollection (in-memory storage for demo) ---
27
+
28
+ // In-memory store simulating a storage backend
29
+ const fakeStorage = new Map<string, string>();
30
+
31
+ class CartCollection extends PersistentCollection<CartItem> {
32
+ // Unique key for this collection in storage
33
+ protected readonly storageKey = 'cart';
34
+
35
+ // --- Implement the abstract persist methods ---
36
+
37
+ protected persistGet(id: string): CartItem | null {
38
+ const raw = fakeStorage.get(this.storageKey);
39
+ if (!raw) return null;
40
+ const items: CartItem[] = this.deserialize(raw);
41
+ return items.find(i => i.id === id) ?? null;
42
+ }
43
+
44
+ protected persistGetAll(): CartItem[] {
45
+ const raw = fakeStorage.get(this.storageKey);
46
+ return raw ? this.deserialize(raw) : [];
47
+ }
48
+
49
+ protected persistSet(items: CartItem[]): void {
50
+ // Merge new/updated items into existing storage
51
+ const existing = this.persistGetAll();
52
+ const map = new Map(existing.map(i => [i.id, i]));
53
+ for (const item of items) {
54
+ map.set(item.id, item);
55
+ }
56
+ fakeStorage.set(this.storageKey, this.serialize([...map.values()]));
57
+ console.log('[Storage] Saved', map.size, 'items');
58
+ }
59
+
60
+ protected persistRemove(ids: string[]): void {
61
+ const existing = this.persistGetAll();
62
+ const filtered = existing.filter(i => !ids.includes(i.id));
63
+ fakeStorage.set(this.storageKey, this.serialize(filtered));
64
+ console.log('[Storage] Removed', ids.length, 'items');
65
+ }
66
+
67
+ protected persistClear(): void {
68
+ fakeStorage.delete(this.storageKey);
69
+ console.log('[Storage] Cleared');
70
+ }
71
+
72
+ // Optional: custom error handling for storage failures
73
+ protected onPersistError(error: unknown) {
74
+ console.error('[Storage] Persist failed:', error);
75
+ }
76
+ }
77
+
78
+ // --- Usage ---
79
+
80
+ const cart = new CartCollection();
81
+
82
+ // hydrate() loads data from storage into the collection.
83
+ // Idempotent — safe to call multiple times.
84
+ // For sync adapters (like WebStorageCollection), hydration is automatic.
85
+ // For async adapters (IndexedDB, NativeCollection), call hydrate() manually.
86
+ await cart.hydrate();
87
+
88
+ console.log('Hydrated:', cart.hydrated); // true
89
+ console.log('Items after hydrate:', cart.length); // 0 (empty storage)
90
+
91
+ // Subscribe to changes
92
+ cart.subscribe((items) => {
93
+ console.log(`Cart: ${items.length} items, total $${items.reduce((s, i) => s + i.price * i.quantity, 0).toFixed(2)}`);
94
+ });
95
+
96
+ // All Collection mutations automatically persist to storage
97
+ cart.add(
98
+ { id: '1', name: 'Widget', quantity: 2, price: 9.99 },
99
+ { id: '2', name: 'Gizmo', quantity: 1, price: 24.99 },
100
+ );
101
+
102
+ // Verify storage was written
103
+ console.log('Storage contents:', fakeStorage.get('cart'));
104
+
105
+ // Update persists the change
106
+ cart.update('1', { quantity: 5 });
107
+
108
+ // Remove persists the deletion
109
+ cart.remove('2');
110
+
111
+ // clearStorage() removes from storage AND clears in-memory
112
+ cart.clearStorage();
113
+ console.log('After clearStorage:', cart.length); // 0
114
+
115
+ // Cleanup — flushes any pending writes before disposing
116
+ cart.dispose();
@@ -0,0 +1,114 @@
1
+ import { Resource, singleton, teardownAll } from 'mvc-kit';
2
+
3
+ // Resource: Collection + async tracking + lifecycle
4
+ //
5
+ // Use Resource when you need a shared data cache with built-in
6
+ // loading/error state for your API calls. It extends Collection
7
+ // with init()/dispose() lifecycle and automatic async method tracking.
8
+ //
9
+ // If you already have a typed API client (tRPC, GraphQL codegen, etc.),
10
+ // call it directly from the Resource — no Service wrapper needed.
11
+
12
+ // --- Entity type ---
13
+
14
+ interface Product {
15
+ id: string;
16
+ name: string;
17
+ price: number;
18
+ category: string;
19
+ }
20
+
21
+ // --- Resource definition ---
22
+
23
+ class ProductsResource extends Resource<Product> {
24
+ // All async methods are automatically tracked after init().
25
+ // Access loading/error state via resource.async.methodName
26
+
27
+ async loadAll() {
28
+ // Pass disposeSignal to cancel in-flight requests on teardown.
29
+ // In production, this would call your API client or fetch().
30
+ const products = await fakeFetch<Product[]>('/api/products', this.disposeSignal);
31
+ this.reset(products);
32
+ }
33
+
34
+ async loadByCategory(category: string) {
35
+ const products = await fakeFetch<Product[]>(
36
+ `/api/products?category=${category}`,
37
+ this.disposeSignal,
38
+ );
39
+ // upsert: add new items or replace existing ones by ID (preserves other items)
40
+ this.upsert(...products);
41
+ }
42
+
43
+ async create(data: Omit<Product, 'id'>) {
44
+ const product = await fakeFetch<Product>('/api/products', this.disposeSignal);
45
+ this.add(product);
46
+ return product;
47
+ }
48
+
49
+ // Lifecycle hook: called after init()
50
+ protected onInit() {
51
+ // Smart-init: only load if the collection is empty.
52
+ // Prevents redundant fetches when multiple consumers share the singleton.
53
+ if (this.length === 0) this.loadAll();
54
+ }
55
+ }
56
+
57
+ // --- Usage ---
58
+
59
+ const products = singleton(ProductsResource);
60
+ await products.init(); // wraps methods for async tracking, calls onInit()
61
+
62
+ // Async tracking — no manual loading/error flags needed
63
+ console.log('Loading:', products.async.loadAll.loading); // true (load started in onInit)
64
+
65
+ // Wait for load to complete
66
+ await products.loadAll();
67
+
68
+ console.log('Loading:', products.async.loadAll.loading); // false
69
+ console.log('Error:', products.async.loadAll.error); // null
70
+ console.log('Total:', products.length); // 3
71
+
72
+ // All Collection methods are available
73
+ console.log('Items:', products.items);
74
+ console.log('Get by id:', products.get('1')?.name);
75
+ console.log('Filter:', products.filter(p => p.price > 20).length);
76
+
77
+ // Subscribe to data changes (inherited from Collection)
78
+ products.subscribe((items) => {
79
+ console.log(`Products changed: ${items.length} items`);
80
+ });
81
+
82
+ // Subscribe to async state changes (loading/error)
83
+ products.subscribeAsync(() => {
84
+ console.log('Async state changed:', products.async.loadAll);
85
+ });
86
+
87
+ // Load more data — upsert merges without duplicates
88
+ await products.loadByCategory('electronics');
89
+
90
+ // Cleanup
91
+ products.dispose();
92
+ teardownAll();
93
+
94
+ // --- Fake API helper ---
95
+
96
+ function fakeFetch<T>(_url: string, signal?: AbortSignal): Promise<T> {
97
+ return new Promise((resolve, reject) => {
98
+ if (signal?.aborted) {
99
+ reject(signal.reason);
100
+ return;
101
+ }
102
+ const timer = setTimeout(() => {
103
+ resolve([
104
+ { id: '1', name: 'Widget', price: 9.99, category: 'gadgets' },
105
+ { id: '2', name: 'Gizmo', price: 24.99, category: 'gadgets' },
106
+ { id: '3', name: 'Doohickey', price: 49.99, category: 'electronics' },
107
+ ] as unknown as T);
108
+ }, 100);
109
+ signal?.addEventListener('abort', () => {
110
+ clearTimeout(timer);
111
+ reject(signal.reason);
112
+ }, { once: true });
113
+ });
114
+ }
@@ -0,0 +1,96 @@
1
+ import { Selection } from 'mvc-kit';
2
+
3
+ // Selection: Key-based selection set with toggle and select-all
4
+ //
5
+ // A subscribable helper that manages a Set of selected keys. Designed
6
+ // to be a property on a ViewModel — auto-tracked so ViewModel getters
7
+ // that read selection state recompute automatically.
8
+ //
9
+ // Methods are auto-bound, so they can be passed point-free as callbacks
10
+ // (e.g., onChange={selection.toggle}).
11
+
12
+ // --- Entity type ---
13
+
14
+ interface File {
15
+ id: string;
16
+ name: string;
17
+ size: number;
18
+ }
19
+
20
+ const files: File[] = [
21
+ { id: '1', name: 'report.pdf', size: 2400 },
22
+ { id: '2', name: 'photo.jpg', size: 5100 },
23
+ { id: '3', name: 'notes.txt', size: 120 },
24
+ { id: '4', name: 'backup.zip', size: 98000 },
25
+ ];
26
+
27
+ const allIds = files.map(f => f.id);
28
+
29
+ // --- Basic usage ---
30
+
31
+ const selection = new Selection<string>();
32
+
33
+ // Subscribe to selection changes
34
+ selection.subscribe(() => {
35
+ console.log('Selected:', [...selection.selected]);
36
+ });
37
+
38
+ // Read current state
39
+ console.log('Count:', selection.count); // 0
40
+ console.log('Has selection:', selection.hasSelection); // false
41
+
42
+ // --- Toggle ---
43
+
44
+ selection.toggle('1');
45
+ console.log('After toggle 1:', selection.isSelected('1')); // true
46
+
47
+ selection.toggle('3');
48
+ console.log('Count:', selection.count); // 2
49
+
50
+ selection.toggle('1'); // toggle off
51
+ console.log('After toggle 1 again:', selection.isSelected('1')); // false
52
+ console.log('Count:', selection.count); // 1
53
+
54
+ // --- Select / Deselect ---
55
+
56
+ selection.select('1', '2', '4');
57
+ console.log('After select:', selection.count); // 4 (3 already had item 3)
58
+
59
+ selection.deselect('2', '4');
60
+ console.log('After deselect:', selection.count); // 2 (items 1 and 3)
61
+
62
+ // --- Toggle all ---
63
+
64
+ // If not all are selected, selects all
65
+ selection.toggleAll(allIds);
66
+ console.log('After toggleAll (select):', selection.count); // 4
67
+
68
+ // If all are selected, deselects all
69
+ selection.toggleAll(allIds);
70
+ console.log('After toggleAll (deselect):', selection.count); // 0
71
+
72
+ // --- Set (atomic replace) ---
73
+
74
+ selection.set('2', '3');
75
+ console.log('After set:', [...selection.selected]); // ['2', '3']
76
+
77
+ // --- Filter items by selection ---
78
+
79
+ selection.set('1', '4');
80
+ const selectedFiles = selection.selectedFrom(files, f => f.id);
81
+ console.log('Selected files:', selectedFiles.map(f => f.name));
82
+ // ['report.pdf', 'backup.zip']
83
+
84
+ // --- Point-free callbacks ---
85
+
86
+ // Methods are auto-bound, so you can pass them directly:
87
+ // <input onChange={selection.toggle} />
88
+ // <button onClick={() => selection.toggleAll(allIds)} />
89
+ const toggleFn = selection.toggle; // no .bind() needed
90
+ toggleFn('3');
91
+ console.log('After point-free toggle:', selection.isSelected('3')); // true
92
+
93
+ // --- Clear ---
94
+
95
+ selection.clear();
96
+ console.log('After clear:', selection.count); // 0
@@ -0,0 +1,112 @@
1
+ import { Sorting } from 'mvc-kit';
2
+
3
+ // Sorting: Multi-column sort state with a comparator pipeline
4
+ //
5
+ // A subscribable helper that manages sort descriptors and applies them
6
+ // to arrays. Designed to be a property on a ViewModel — auto-tracked
7
+ // so ViewModel getters that read sorting state recompute automatically.
8
+ //
9
+ // Three-click cycle: unsorted → asc → desc → removed.
10
+
11
+ // --- Entity type ---
12
+
13
+ interface Employee {
14
+ id: string;
15
+ name: string;
16
+ department: string;
17
+ salary: number;
18
+ startDate: string;
19
+ }
20
+
21
+ const employees: Employee[] = [
22
+ { id: '1', name: 'Alice', department: 'Engineering', salary: 120000, startDate: '2020-03-15' },
23
+ { id: '2', name: 'Bob', department: 'Marketing', salary: 85000, startDate: '2021-06-01' },
24
+ { id: '3', name: 'Carol', department: 'Engineering', salary: 140000, startDate: '2019-01-10' },
25
+ { id: '4', name: 'Dave', department: 'Marketing', salary: 92000, startDate: '2022-09-20' },
26
+ { id: '5', name: 'Eve', department: 'Engineering', salary: 110000, startDate: '2023-02-01' },
27
+ ];
28
+
29
+ // --- Basic usage ---
30
+
31
+ const sorting = new Sorting<Employee>();
32
+
33
+ // Subscribe to sort state changes
34
+ sorting.subscribe(() => {
35
+ console.log('Sort changed:', sorting.sorts);
36
+ });
37
+
38
+ // Read current state
39
+ console.log('Key:', sorting.key); // null (no sort active)
40
+ console.log('Direction:', sorting.direction); // 'asc' (default)
41
+
42
+ // Toggle cycles: unsorted → asc → desc → removed
43
+ sorting.toggle('name');
44
+ console.log('After first toggle:', sorting.key, sorting.direction); // 'name', 'asc'
45
+
46
+ sorting.toggle('name');
47
+ console.log('After second toggle:', sorting.key, sorting.direction); // 'name', 'desc'
48
+
49
+ sorting.toggle('name');
50
+ console.log('After third toggle:', sorting.key); // null (removed)
51
+
52
+ // --- Apply sort pipeline ---
53
+
54
+ sorting.setSort('salary', 'desc');
55
+
56
+ const bySalary = sorting.apply(employees);
57
+ console.log('By salary (desc):', bySalary.map(e => `${e.name}: $${e.salary}`));
58
+ // Carol: $140000, Alice: $120000, Eve: $110000, Dave: $92000, Bob: $85000
59
+
60
+ // --- Query methods ---
61
+
62
+ console.log('Is salary sorted:', sorting.isSorted('salary')); // true
63
+ console.log('Salary direction:', sorting.directionOf('salary')); // 'desc'
64
+ console.log('Name direction:', sorting.directionOf('name')); // null (not sorted)
65
+
66
+ // --- Multi-column sort ---
67
+
68
+ // setSorts replaces all descriptors
69
+ sorting.setSorts([
70
+ { key: 'department', direction: 'asc' },
71
+ { key: 'salary', direction: 'desc' },
72
+ ]);
73
+
74
+ const multiSorted = sorting.apply(employees);
75
+ console.log('Multi-sort (dept asc, salary desc):');
76
+ multiSorted.forEach(e => console.log(` ${e.department} — ${e.name}: $${e.salary}`));
77
+ // Engineering — Carol: $140000
78
+ // Engineering — Alice: $120000
79
+ // Engineering — Eve: $110000
80
+ // Marketing — Dave: $92000
81
+ // Marketing — Bob: $85000
82
+
83
+ console.log('Salary index in sort list:', sorting.indexOf('salary')); // 1
84
+
85
+ // --- Custom comparator ---
86
+
87
+ // Custom compareFn for special types (dates, locale-specific strings, etc.)
88
+ sorting.setSort('startDate', 'asc');
89
+
90
+ const byDate = sorting.apply(employees, (a, b, key, dir) => {
91
+ const aVal = a[key as keyof Employee];
92
+ const bVal = b[key as keyof Employee];
93
+ // Date strings sort correctly with localeCompare, but you could parse to Date
94
+ const cmp = String(aVal).localeCompare(String(bVal));
95
+ return dir === 'asc' ? cmp : -cmp;
96
+ });
97
+ console.log('By start date:', byDate.map(e => `${e.name} (${e.startDate})`));
98
+
99
+ // --- Initial sorts via constructor ---
100
+
101
+ const preConfigured = new Sorting<Employee>({
102
+ sorts: [{ key: 'name', direction: 'asc' }],
103
+ });
104
+ console.log('Pre-configured key:', preConfigured.key); // 'name'
105
+
106
+ const sorted = preConfigured.apply(employees);
107
+ console.log('Pre-sorted:', sorted.map(e => e.name)); // Alice, Bob, Carol, Dave, Eve
108
+
109
+ // --- Reset ---
110
+
111
+ sorting.reset();
112
+ console.log('After reset key:', sorting.key); // null
@@ -0,0 +1,58 @@
1
+ import { ViewModel } from 'mvc-kit';
2
+
3
+ // ViewModel: addCleanup pattern for resource management
4
+ //
5
+ // addCleanup() registers teardown callbacks that fire on dispose().
6
+ // Use it for intervals, event listeners, or any resource that needs
7
+ // explicit cleanup. React hooks (useLocal, useSingleton) auto-call
8
+ // dispose() on unmount, so cleanups run automatically.
9
+
10
+ interface TimerState {
11
+ elapsed: number;
12
+ running: boolean;
13
+ }
14
+
15
+ class TimerViewModel extends ViewModel<TimerState> {
16
+ private intervalId?: ReturnType<typeof setInterval>;
17
+
18
+ start() {
19
+ if (this.state.running) return;
20
+
21
+ this.set({ running: true });
22
+ this.intervalId = setInterval(() => {
23
+ this.set(prev => ({ elapsed: prev.elapsed + 1 }));
24
+ }, 1000);
25
+
26
+ // addCleanup registers a teardown callback that fires on dispose()
27
+ // Safe to call multiple times — clearInterval on an already-cleared ID is a no-op
28
+ this.addCleanup(() => clearInterval(this.intervalId));
29
+ }
30
+
31
+ stop() {
32
+ if (!this.state.running) return;
33
+
34
+ clearInterval(this.intervalId);
35
+ this.intervalId = undefined;
36
+ this.set({ running: false });
37
+ }
38
+
39
+ reset() {
40
+ this.stop();
41
+ this.set({ elapsed: 0 });
42
+ }
43
+ }
44
+
45
+ // Usage
46
+ const timer = new TimerViewModel({ elapsed: 0, running: false });
47
+
48
+ timer.subscribe((state) => {
49
+ console.log(`Timer: ${state.elapsed}s (${state.running ? 'running' : 'stopped'})`);
50
+ });
51
+
52
+ timer.start();
53
+
54
+ // Stop after 5 seconds
55
+ setTimeout(() => {
56
+ timer.stop();
57
+ timer.dispose();
58
+ }, 5000);