mvc-kit 2.13.0 → 2.13.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 (200) hide show
  1. package/BEST_PRACTICES.md +1390 -0
  2. package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
  3. package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
  4. package/agent-config/lib/install-claude.mjs +8 -2
  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
@@ -0,0 +1,162 @@
1
+ import { Feed } from 'mvc-kit';
2
+
3
+ // Feed: Cursor-based pagination for infinite scroll / load-more
4
+ //
5
+ // A subscribable helper that accumulates items across pages using
6
+ // cursor-based server pagination. Tracks cursor position, hasMore
7
+ // status, and the growing item list.
8
+ //
9
+ // Designed to be a property on a ViewModel — auto-tracked so
10
+ // ViewModel getters that read feed state recompute automatically.
11
+ //
12
+ // Typical flow:
13
+ // 1. ViewModel calls API with feed.cursor
14
+ // 2. API returns { items, hasMore, cursor }
15
+ // 3. ViewModel calls feed.appendPage(result)
16
+ // 4. Repeat until hasMore is false
17
+
18
+ // --- Entity type ---
19
+
20
+ interface Post {
21
+ id: string;
22
+ title: string;
23
+ createdAt: string;
24
+ }
25
+
26
+ // --- Simulated API ---
27
+
28
+ function fakeFetchPosts(cursor: string | null): {
29
+ items: Post[];
30
+ hasMore: boolean;
31
+ cursor: string;
32
+ } {
33
+ const pages: Record<string, { items: Post[]; hasMore: boolean; cursor: string }> = {
34
+ initial: {
35
+ items: [
36
+ { id: '1', title: 'First post', createdAt: '2024-01-01' },
37
+ { id: '2', title: 'Second post', createdAt: '2024-01-02' },
38
+ { id: '3', title: 'Third post', createdAt: '2024-01-03' },
39
+ ],
40
+ hasMore: true,
41
+ cursor: 'page2',
42
+ },
43
+ page2: {
44
+ items: [
45
+ { id: '4', title: 'Fourth post', createdAt: '2024-01-04' },
46
+ { id: '5', title: 'Fifth post', createdAt: '2024-01-05' },
47
+ ],
48
+ hasMore: true,
49
+ cursor: 'page3',
50
+ },
51
+ page3: {
52
+ items: [
53
+ { id: '6', title: 'Sixth post', createdAt: '2024-01-06' },
54
+ ],
55
+ hasMore: false,
56
+ cursor: 'end',
57
+ },
58
+ };
59
+
60
+ const key = cursor ?? 'initial';
61
+ return pages[key]!;
62
+ }
63
+
64
+ // --- Basic usage ---
65
+
66
+ const feed = new Feed<Post>();
67
+
68
+ // Subscribe to feed state changes
69
+ feed.subscribe(() => {
70
+ console.log(`Feed: ${feed.count} items, hasMore=${feed.hasMore}, cursor=${feed.cursor}`);
71
+ });
72
+
73
+ // Initial state
74
+ console.log('Cursor:', feed.cursor); // null
75
+ console.log('Has more:', feed.hasMore); // true
76
+ console.log('Items:', feed.count); // 0
77
+
78
+ // --- Load first page ---
79
+
80
+ const page1 = fakeFetchPosts(feed.cursor);
81
+ feed.appendPage(page1);
82
+
83
+ console.log('After page 1:', feed.count); // 3
84
+ console.log('Cursor:', feed.cursor); // 'page2'
85
+ console.log('Has more:', feed.hasMore); // true
86
+
87
+ // --- Load next page (items accumulate) ---
88
+
89
+ const page2 = fakeFetchPosts(feed.cursor);
90
+ feed.appendPage(page2);
91
+
92
+ console.log('After page 2:', feed.count); // 5
93
+ console.log('Cursor:', feed.cursor); // 'page3'
94
+
95
+ // --- Load final page ---
96
+
97
+ const page3 = fakeFetchPosts(feed.cursor);
98
+ feed.appendPage(page3);
99
+
100
+ console.log('After page 3:', feed.count); // 6
101
+ console.log('Has more:', feed.hasMore); // false (no more pages)
102
+
103
+ // Access all accumulated items
104
+ console.log('All items:', feed.items.map(p => p.title));
105
+
106
+ // --- Prepend (for chat UIs / newest-first feeds) ---
107
+
108
+ const chatFeed = new Feed<Post>();
109
+
110
+ chatFeed.appendPage({
111
+ items: [
112
+ { id: 'c1', title: 'Latest message', createdAt: '2024-01-10' },
113
+ { id: 'c2', title: 'Previous message', createdAt: '2024-01-09' },
114
+ ],
115
+ hasMore: true,
116
+ cursor: 'older',
117
+ });
118
+
119
+ // Prepend older messages at the top (e.g., scrolling up in a chat)
120
+ chatFeed.prependPage({
121
+ items: [
122
+ { id: 'c3', title: 'Oldest message', createdAt: '2024-01-08' },
123
+ ],
124
+ hasMore: false,
125
+ cursor: 'start',
126
+ });
127
+
128
+ console.log('Chat order:', chatFeed.items.map(p => p.title));
129
+ // ['Oldest message', 'Latest message', 'Previous message']
130
+
131
+ // --- Push items without affecting cursor ---
132
+
133
+ feed.push({ id: '7', title: 'Optimistic post', createdAt: '2024-01-07' });
134
+ console.log('After push:', feed.count); // 7
135
+
136
+ // --- Filter items ---
137
+
138
+ feed.filter(p => p.id !== '7'); // remove the optimistic post
139
+ console.log('After filter:', feed.count); // 6
140
+
141
+ // --- Pull-to-refresh (replacePage) ---
142
+
143
+ feed.replacePage({
144
+ items: [
145
+ { id: 'r1', title: 'Refreshed post 1', createdAt: '2024-02-01' },
146
+ { id: 'r2', title: 'Refreshed post 2', createdAt: '2024-02-02' },
147
+ ],
148
+ hasMore: true,
149
+ cursor: 'refreshed-page2',
150
+ });
151
+ console.log('After replacePage:', feed.count); // 2 (replaced all)
152
+
153
+ // --- setResult (update cursor/hasMore only) ---
154
+
155
+ feed.setResult({ hasMore: false, cursor: 'final' });
156
+ console.log('After setResult hasMore:', feed.hasMore); // false
157
+ console.log('Items unchanged:', feed.count); // 2
158
+
159
+ // --- Reset ---
160
+
161
+ feed.reset();
162
+ console.log('After reset:', feed.count, feed.cursor, feed.hasMore); // 0, null, true
@@ -0,0 +1,82 @@
1
+ import { Model } from 'mvc-kit';
2
+
3
+ // Model: Reactive entity with validation and dirty tracking
4
+
5
+ interface UserFormState {
6
+ name: string;
7
+ email: string;
8
+ age: number;
9
+ }
10
+
11
+ class UserFormModel extends Model<UserFormState> {
12
+ setName(name: string) {
13
+ this.set({ name });
14
+ }
15
+
16
+ setEmail(email: string) {
17
+ this.set({ email });
18
+ }
19
+
20
+ setAge(age: number) {
21
+ this.set({ age });
22
+ }
23
+
24
+ // Override to provide validation logic
25
+ protected validate(state: UserFormState) {
26
+ const errors: Partial<Record<keyof UserFormState, string>> = {};
27
+
28
+ if (!state.name.trim()) {
29
+ errors.name = 'Name is required';
30
+ }
31
+
32
+ if (!state.email.includes('@')) {
33
+ errors.email = 'Invalid email address';
34
+ }
35
+
36
+ if (state.age < 0 || state.age > 150) {
37
+ errors.age = 'Age must be between 0 and 150';
38
+ }
39
+
40
+ return errors;
41
+ }
42
+ }
43
+
44
+ // --- Usage ---
45
+
46
+ const form = new UserFormModel({ name: '', email: '', age: 0 });
47
+
48
+ // Subscribe to state changes
49
+ form.subscribe((state) => {
50
+ console.log('Form state:', state);
51
+ });
52
+
53
+ // Check validation
54
+ console.log('Initial valid:', form.valid); // false
55
+ console.log('Initial errors:', form.errors); // { name: 'Name is required', email: 'Invalid email...' }
56
+
57
+ // Update fields
58
+ form.setName('John Doe');
59
+ form.setEmail('john@example.com');
60
+ form.setAge(30);
61
+
62
+ console.log('After updates valid:', form.valid); // true
63
+ console.log('After updates errors:', form.errors); // {}
64
+
65
+ // Dirty tracking - check if state differs from committed baseline
66
+ console.log('Is dirty:', form.dirty); // true (differs from initial state)
67
+
68
+ // Commit - mark current state as the new baseline
69
+ form.commit();
70
+ console.log('After commit dirty:', form.dirty); // false
71
+
72
+ // Make more changes
73
+ form.setName('Jane Doe');
74
+ console.log('After change dirty:', form.dirty); // true
75
+
76
+ // Rollback - revert to committed state
77
+ form.rollback();
78
+ console.log('After rollback name:', form.state.name); // 'John Doe'
79
+ console.log('After rollback dirty:', form.dirty); // false
80
+
81
+ // Cleanup
82
+ form.dispose();
@@ -0,0 +1,91 @@
1
+ import { Pagination } from 'mvc-kit';
2
+
3
+ // Pagination: Page-based state with array slicing pipeline
4
+ //
5
+ // A subscribable helper that manages page/pageSize state and slices
6
+ // arrays accordingly. Designed to be a property on a ViewModel —
7
+ // auto-tracked so ViewModel getters that read pagination state
8
+ // recompute automatically.
9
+
10
+ // --- Sample data ---
11
+
12
+ const allItems = Array.from({ length: 47 }, (_, i) => ({
13
+ id: String(i + 1),
14
+ title: `Item ${i + 1}`,
15
+ }));
16
+
17
+ // --- Basic usage ---
18
+
19
+ const pagination = new Pagination(); // default pageSize: 10
20
+
21
+ // Subscribe to page changes
22
+ pagination.subscribe(() => {
23
+ console.log(`Page ${pagination.page} of ${pagination.pageCount(allItems.length)}`);
24
+ });
25
+
26
+ // Read current state
27
+ console.log('Page:', pagination.page); // 1
28
+ console.log('Page size:', pagination.pageSize); // 10
29
+
30
+ // --- Apply slice pipeline ---
31
+
32
+ const page1 = pagination.apply(allItems);
33
+ console.log('Page 1 items:', page1.length); // 10
34
+ console.log('First:', page1[0].title); // 'Item 1'
35
+ console.log('Last:', page1[9].title); // 'Item 10'
36
+
37
+ // --- Navigation ---
38
+
39
+ pagination.nextPage();
40
+ const page2 = pagination.apply(allItems);
41
+ console.log('Page 2 first:', page2[0].title); // 'Item 11'
42
+
43
+ pagination.nextPage();
44
+ pagination.nextPage();
45
+ pagination.nextPage(); // page 5 (last page)
46
+
47
+ const lastPage = pagination.apply(allItems);
48
+ console.log('Last page items:', lastPage.length); // 7 (47 - 40)
49
+ console.log('Last page first:', lastPage[0].title); // 'Item 41'
50
+
51
+ // --- Boundary checks ---
52
+
53
+ console.log('Page count:', pagination.pageCount(allItems.length)); // 5
54
+ console.log('Has next:', pagination.hasNext(allItems.length)); // false (on last page)
55
+ console.log('Has prev:', pagination.hasPrev()); // true
56
+
57
+ pagination.prevPage();
58
+ console.log('After prevPage:', pagination.page); // 4
59
+
60
+ // prevPage on page 1 is a no-op
61
+ pagination.setPage(1);
62
+ pagination.prevPage();
63
+ console.log('After prevPage on page 1:', pagination.page); // 1
64
+
65
+ // --- Change page size ---
66
+
67
+ pagination.setPageSize(20); // resets to page 1 automatically
68
+ console.log('New page size:', pagination.pageSize); // 20
69
+ console.log('Page after resize:', pagination.page); // 1
70
+ console.log('New page count:', pagination.pageCount(allItems.length)); // 3
71
+
72
+ const bigPage = pagination.apply(allItems);
73
+ console.log('Items on resized page:', bigPage.length); // 20
74
+
75
+ // --- Navigate to specific page ---
76
+
77
+ pagination.setPage(3);
78
+ const thirdPage = pagination.apply(allItems);
79
+ console.log('Page 3 items:', thirdPage.length); // 7 (47 - 40)
80
+
81
+ // --- Custom initial page size ---
82
+
83
+ const smallPages = new Pagination({ pageSize: 5 });
84
+ console.log('Custom page size:', smallPages.pageSize); // 5
85
+ console.log('Custom page count:', smallPages.pageCount(allItems.length)); // 10
86
+
87
+ // --- Reset ---
88
+
89
+ pagination.setPage(3);
90
+ pagination.reset(); // resets to page 1
91
+ console.log('After reset page:', pagination.page); // 1
@@ -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();