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.
- package/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +6 -1
- package/agent-config/claude-code/skills/guide/SKILL.md +9 -0
- package/agent-config/lib/install-claude.mjs +8 -2
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +4 -2
|
@@ -21,7 +21,12 @@ The mvc-kit framework reference skill is preloaded into this agent's context. Fo
|
|
|
21
21
|
- `recipes.md` — Composition recipes for real-world features
|
|
22
22
|
- `testing.md` — Testing patterns
|
|
23
23
|
|
|
24
|
-
For
|
|
24
|
+
For deep dives on any specific class or hook, search for the `.md` file by name in `node_modules/mvc-kit/src/`:
|
|
25
|
+
|
|
26
|
+
- **Core:** `ViewModel.md`, `Model.md`, `Collection.md`, `PersistentCollection.md`, `Resource.md`, `Service.md`, `EventBus.md`, `Channel.md`, `Controller.md`, `Trackable.md`, `singleton.md`
|
|
27
|
+
- **Helpers:** `Sorting.md`, `Pagination.md`, `Selection.md`, `Feed.md`, `Pending.md`, `produceDraft.md`
|
|
28
|
+
- **React hooks:** `react/use-local.md`, `react/use-instance.md`, `react/use-singleton.md`, `react/use-model.md`, `react/use-event-bus.md`, `react/use-teardown.md`
|
|
29
|
+
- **Components:** `react/components/DataTable.md`, `react/components/CardList.md`, `react/components/InfiniteScroll.md`
|
|
25
30
|
|
|
26
31
|
## Core Classes
|
|
27
32
|
|
|
@@ -97,3 +97,12 @@ For full composition recipes with code, see [recipes.md](recipes.md).
|
|
|
97
97
|
- [anti-patterns.md](anti-patterns.md) — Anti-patterns to reject with fixes
|
|
98
98
|
- [recipes.md](recipes.md) — Composition recipes for real-world features
|
|
99
99
|
- [testing.md](testing.md) — Testing patterns (teardownAll, async assertions, memoization verification)
|
|
100
|
+
|
|
101
|
+
### Per-Class Documentation (in `node_modules/mvc-kit/src/`)
|
|
102
|
+
|
|
103
|
+
For deep dives on any specific class or hook, search for the `.md` file by name:
|
|
104
|
+
|
|
105
|
+
- **Core:** `ViewModel.md`, `Model.md`, `Collection.md`, `PersistentCollection.md`, `Resource.md`, `Service.md`, `EventBus.md`, `Channel.md`, `Controller.md`, `Trackable.md`, `singleton.md`
|
|
106
|
+
- **Helpers:** `Sorting.md`, `Pagination.md`, `Selection.md`, `Feed.md`, `Pending.md`, `produceDraft.md`
|
|
107
|
+
- **React hooks:** `react/use-local.md`, `react/use-instance.md`, `react/use-singleton.md`, `react/use-model.md`, `react/use-event-bus.md`, `react/use-teardown.md`
|
|
108
|
+
- **Components:** `react/components/DataTable.md`, `react/components/CardList.md`, `react/components/InfiniteScroll.md`
|
|
@@ -80,8 +80,14 @@ user-invocable: false
|
|
|
80
80
|
- [recipes.md](recipes.md) — Composition recipes for real-world features
|
|
81
81
|
- [testing.md](testing.md) — Testing patterns (teardownAll, async assertions, memoization verification)
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
### Per-Class Documentation (in \`node_modules/mvc-kit/src/\`)
|
|
84
|
+
|
|
85
|
+
For deep dives on any specific class or hook, search for the \`.md\` file by name:
|
|
86
|
+
|
|
87
|
+
- **Core:** \`ViewModel.md\`, \`Model.md\`, \`Collection.md\`, \`PersistentCollection.md\`, \`Resource.md\`, \`Service.md\`, \`EventBus.md\`, \`Channel.md\`, \`Controller.md\`, \`Trackable.md\`, \`singleton.md\`
|
|
88
|
+
- **Helpers:** \`Sorting.md\`, \`Pagination.md\`, \`Selection.md\`, \`Feed.md\`, \`Pending.md\`, \`produceDraft.md\`
|
|
89
|
+
- **React hooks:** \`react/use-local.md\`, \`react/use-instance.md\`, \`react/use-singleton.md\`, \`react/use-model.md\`, \`react/use-event-bus.md\`, \`react/use-teardown.md\`
|
|
90
|
+
- **Components:** \`react/components/DataTable.md\`, \`react/components/CardList.md\`, \`react/components/InfiniteScroll.md\`
|
|
85
91
|
`;
|
|
86
92
|
|
|
87
93
|
writeFileSync(join(guideDir, 'SKILL.md'), skillContent, 'utf-8');
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Channel, singleton, teardownAll } from 'mvc-kit';
|
|
2
|
+
import type { ChannelStatus } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
// Channel: Persistent connection with auto-reconnect and typed messages
|
|
5
|
+
//
|
|
6
|
+
// Extend Channel<MessageMap> and implement two abstract methods:
|
|
7
|
+
// open(signal) — establish the connection (WebSocket, SSE, etc.)
|
|
8
|
+
// close() — tear down the transport
|
|
9
|
+
//
|
|
10
|
+
// The framework handles connection status, reconnect with exponential
|
|
11
|
+
// backoff, message routing, and lifecycle management.
|
|
12
|
+
|
|
13
|
+
// --- Message type map ---
|
|
14
|
+
|
|
15
|
+
interface ChatMessages {
|
|
16
|
+
message: { userId: string; text: string };
|
|
17
|
+
typing: { userId: string };
|
|
18
|
+
presence: { online: string[] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Channel subclass ---
|
|
22
|
+
|
|
23
|
+
class ChatChannel extends Channel<ChatMessages> {
|
|
24
|
+
// Tune reconnect behavior via static overrides
|
|
25
|
+
static override RECONNECT_BASE = 1000; // initial backoff (ms)
|
|
26
|
+
static override RECONNECT_MAX = 30000; // max backoff cap (ms)
|
|
27
|
+
static override MAX_ATTEMPTS = 5; // give up after 5 attempts
|
|
28
|
+
|
|
29
|
+
private ws: { close(): void } | null = null;
|
|
30
|
+
|
|
31
|
+
// open() is called by the framework when connect() is invoked.
|
|
32
|
+
// The signal aborts on disconnect() or dispose().
|
|
33
|
+
protected open(signal: AbortSignal): void {
|
|
34
|
+
// In production, this would be:
|
|
35
|
+
// this.ws = new WebSocket('wss://chat.example.com');
|
|
36
|
+
// this.ws.onmessage = (e) => {
|
|
37
|
+
// const { type, payload } = JSON.parse(e.data);
|
|
38
|
+
// this.receive(type, payload);
|
|
39
|
+
// };
|
|
40
|
+
|
|
41
|
+
// Simulated connection
|
|
42
|
+
console.log('Connecting...');
|
|
43
|
+
const connectTimer = setTimeout(() => {
|
|
44
|
+
if (!signal.aborted) {
|
|
45
|
+
console.log('Connected!');
|
|
46
|
+
// Simulate incoming messages
|
|
47
|
+
this.receive('presence', { online: ['alice', 'bob'] });
|
|
48
|
+
this.receive('message', { userId: 'alice', text: 'Hello!' });
|
|
49
|
+
}
|
|
50
|
+
}, 100);
|
|
51
|
+
|
|
52
|
+
this.ws = {
|
|
53
|
+
close: () => clearTimeout(connectTimer),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Always clean up when the signal aborts (disconnect or dispose)
|
|
57
|
+
signal.addEventListener('abort', () => this.ws?.close());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// close() tears down the transport. Must not throw.
|
|
61
|
+
protected close(): void {
|
|
62
|
+
this.ws?.close();
|
|
63
|
+
this.ws = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Usage ---
|
|
68
|
+
|
|
69
|
+
const chat = singleton(ChatChannel);
|
|
70
|
+
chat.init();
|
|
71
|
+
|
|
72
|
+
// Subscribe to connection status changes
|
|
73
|
+
chat.subscribe((next: ChannelStatus, prev: ChannelStatus) => {
|
|
74
|
+
console.log(`Status: connected=${next.connected}, reconnecting=${next.reconnecting}`);
|
|
75
|
+
if (next.error) console.log(`Error: ${next.error}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Subscribe to typed messages
|
|
79
|
+
const unsubMessage = chat.on('message', ({ userId, text }) => {
|
|
80
|
+
console.log(`${userId}: ${text}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// One-time subscription — auto-unsubscribes after first event
|
|
84
|
+
chat.once('presence', ({ online }) => {
|
|
85
|
+
console.log('Initial presence:', online);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Initiate connection
|
|
89
|
+
chat.connect();
|
|
90
|
+
|
|
91
|
+
// Read current status at any time
|
|
92
|
+
console.log('Connected:', chat.state.connected);
|
|
93
|
+
console.log('Reconnecting:', chat.state.reconnecting);
|
|
94
|
+
console.log('Attempt:', chat.state.attempt);
|
|
95
|
+
|
|
96
|
+
// Manually disconnect (cancels pending reconnect, resets status)
|
|
97
|
+
// chat.disconnect();
|
|
98
|
+
|
|
99
|
+
// Reconnect after disconnect
|
|
100
|
+
// chat.connect();
|
|
101
|
+
|
|
102
|
+
// Unsubscribe from a message type
|
|
103
|
+
unsubMessage();
|
|
104
|
+
|
|
105
|
+
// Cleanup — cancels timers, aborts signals, calls close()
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
chat.dispose();
|
|
108
|
+
teardownAll();
|
|
109
|
+
}, 500);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Collection } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Collection: Reactive typed array with CRUD and query methods
|
|
4
|
+
|
|
5
|
+
interface Todo {
|
|
6
|
+
id: string;
|
|
7
|
+
text: string;
|
|
8
|
+
done: boolean;
|
|
9
|
+
priority: 'low' | 'medium' | 'high';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// --- Basic usage ---
|
|
13
|
+
|
|
14
|
+
const todos = new Collection<Todo>();
|
|
15
|
+
|
|
16
|
+
// Subscribe to changes
|
|
17
|
+
todos.subscribe((items, prev) => {
|
|
18
|
+
console.log(`Collection changed: ${prev.length} → ${items.length} items`);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// CRUD operations (trigger notifications)
|
|
22
|
+
|
|
23
|
+
// Add items
|
|
24
|
+
todos.add(
|
|
25
|
+
{ id: '1', text: 'Learn mvc-kit', done: false, priority: 'high' },
|
|
26
|
+
{ id: '2', text: 'Build an app', done: false, priority: 'medium' },
|
|
27
|
+
{ id: '3', text: 'Write tests', done: true, priority: 'low' }
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
console.log('Length:', todos.length); // 3
|
|
31
|
+
console.log('Items:', todos.items);
|
|
32
|
+
|
|
33
|
+
// Update an item
|
|
34
|
+
todos.update('1', { done: true });
|
|
35
|
+
console.log('After update:', todos.get('1')?.done); // true
|
|
36
|
+
|
|
37
|
+
// Remove an item
|
|
38
|
+
todos.remove('3');
|
|
39
|
+
console.log('After remove length:', todos.length); // 2
|
|
40
|
+
|
|
41
|
+
// Query operations (pure, no notifications)
|
|
42
|
+
|
|
43
|
+
// Get by id (O(1) lookup)
|
|
44
|
+
const todo = todos.get('1');
|
|
45
|
+
console.log('Get by id:', todo?.text);
|
|
46
|
+
|
|
47
|
+
// Check existence
|
|
48
|
+
console.log('Has id 1:', todos.has('1')); // true
|
|
49
|
+
console.log('Has id 99:', todos.has('99')); // false
|
|
50
|
+
|
|
51
|
+
// Find first match
|
|
52
|
+
const firstIncomplete = todos.find(t => !t.done);
|
|
53
|
+
console.log('First incomplete:', firstIncomplete?.text);
|
|
54
|
+
|
|
55
|
+
// Filter
|
|
56
|
+
const highPriority = todos.filter(t => t.priority === 'high');
|
|
57
|
+
console.log('High priority count:', highPriority.length);
|
|
58
|
+
|
|
59
|
+
// Sort (returns new array, doesn't mutate)
|
|
60
|
+
const sorted = todos.sorted((a, b) => a.text.localeCompare(b.text));
|
|
61
|
+
console.log('Sorted:', sorted.map(t => t.text));
|
|
62
|
+
|
|
63
|
+
// Map
|
|
64
|
+
const texts = todos.map(t => t.text);
|
|
65
|
+
console.log('Texts:', texts);
|
|
66
|
+
|
|
67
|
+
// Reset - replace all items
|
|
68
|
+
todos.reset([
|
|
69
|
+
{ id: 'a', text: 'New todo', done: false, priority: 'medium' }
|
|
70
|
+
]);
|
|
71
|
+
console.log('After reset length:', todos.length); // 1
|
|
72
|
+
|
|
73
|
+
// Clear all items
|
|
74
|
+
todos.clear();
|
|
75
|
+
console.log('After clear length:', todos.length); // 0
|
|
76
|
+
|
|
77
|
+
// --- Upsert: add-or-replace by ID ---
|
|
78
|
+
|
|
79
|
+
todos.reset([
|
|
80
|
+
{ id: '1', text: 'Learn mvc-kit', done: false, priority: 'high' },
|
|
81
|
+
{ id: '2', text: 'Build an app', done: false, priority: 'medium' },
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// Upsert replaces existing items in-place and appends new ones
|
|
85
|
+
todos.upsert(
|
|
86
|
+
{ id: '2', text: 'Build an app', done: true, priority: 'medium' }, // replaces in position
|
|
87
|
+
{ id: '3', text: 'Ship it', done: false, priority: 'high' }, // appended
|
|
88
|
+
);
|
|
89
|
+
console.log('After upsert length:', todos.length); // 3
|
|
90
|
+
console.log('Item 2 done:', todos.get('2')?.done); // true (replaced)
|
|
91
|
+
console.log('Item 3 text:', todos.get('3')?.text); // 'Ship it' (new)
|
|
92
|
+
|
|
93
|
+
// --- Optimistic updates ---
|
|
94
|
+
|
|
95
|
+
// Reset with some data for the optimistic demo
|
|
96
|
+
todos.reset([
|
|
97
|
+
{ id: '1', text: 'Learn mvc-kit', done: false, priority: 'high' },
|
|
98
|
+
{ id: '2', text: 'Build an app', done: false, priority: 'medium' },
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// Snapshot current state, apply mutations, get a rollback function
|
|
102
|
+
const rollback = todos.optimistic(() => {
|
|
103
|
+
todos.update('1', { done: true });
|
|
104
|
+
todos.remove('2');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
console.log('After optimistic:', todos.length); // 1
|
|
108
|
+
console.log('Item 1 done:', todos.get('1')?.done); // true
|
|
109
|
+
|
|
110
|
+
// If the server call fails, rollback restores pre-optimistic state
|
|
111
|
+
rollback();
|
|
112
|
+
|
|
113
|
+
console.log('After rollback:', todos.length); // 2
|
|
114
|
+
console.log('Item 1 done:', todos.get('1')?.done); // false
|
|
115
|
+
console.log('Item 2 exists:', todos.has('2')); // true
|
|
116
|
+
|
|
117
|
+
// Cleanup
|
|
118
|
+
todos.dispose();
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Controller, ViewModel, Collection, singleton, teardownAll } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// Controller: Stateless multi-ViewModel orchestrator
|
|
4
|
+
//
|
|
5
|
+
// Controllers coordinate between ViewModels, Models, and Services when
|
|
6
|
+
// a single ViewModel can't own the workflow. This is rare — most
|
|
7
|
+
// orchestration fits in a single ViewModel.
|
|
8
|
+
//
|
|
9
|
+
// Use Controller only for pure cross-cutting coordination with no state
|
|
10
|
+
// of its own: multi-step checkout, drag-and-drop between lists, etc.
|
|
11
|
+
//
|
|
12
|
+
// What Controller provides:
|
|
13
|
+
// - init() / dispose() lifecycle
|
|
14
|
+
// - subscribeTo() / listenTo() with auto-cleanup
|
|
15
|
+
// - disposeSignal for cancelling async operations
|
|
16
|
+
// - addCleanup() for custom teardown
|
|
17
|
+
//
|
|
18
|
+
// What Controller does NOT provide:
|
|
19
|
+
// - State (no set())
|
|
20
|
+
// - Computed getters
|
|
21
|
+
// - Async tracking
|
|
22
|
+
// - Events (no emit())
|
|
23
|
+
|
|
24
|
+
// --- Supporting types ---
|
|
25
|
+
|
|
26
|
+
interface Task {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
status: 'todo' | 'done';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TaskListState {
|
|
33
|
+
items: Task[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Two ViewModels that the Controller coordinates ---
|
|
37
|
+
|
|
38
|
+
class TodoListViewModel extends ViewModel<TaskListState> {
|
|
39
|
+
removeItem(id: string) {
|
|
40
|
+
this.set({ items: this.state.items.filter(t => t.id !== id) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
addItem(task: Task) {
|
|
44
|
+
this.set({ items: [...this.state.items, task] });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class DoneListViewModel extends ViewModel<TaskListState> {
|
|
49
|
+
addItem(task: Task) {
|
|
50
|
+
this.set({ items: [...this.state.items, { ...task, status: 'done' as const }] });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Controller definition ---
|
|
55
|
+
|
|
56
|
+
class TaskTransferController extends Controller {
|
|
57
|
+
constructor(
|
|
58
|
+
private todoVM: TodoListViewModel,
|
|
59
|
+
private doneVM: DoneListViewModel,
|
|
60
|
+
) {
|
|
61
|
+
super();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected onInit() {
|
|
65
|
+
// subscribeTo auto-cleans up on dispose
|
|
66
|
+
this.subscribeTo(this.todoVM, (state) => {
|
|
67
|
+
console.log(`Todo list: ${state.items.length} items`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this.subscribeTo(this.doneVM, (state) => {
|
|
71
|
+
console.log(`Done list: ${state.items.length} items`);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Pure coordination — moves an item from todo to done
|
|
76
|
+
completeTask(taskId: string) {
|
|
77
|
+
const task = this.todoVM.state.items.find(t => t.id === taskId);
|
|
78
|
+
if (!task) return;
|
|
79
|
+
this.todoVM.removeItem(taskId);
|
|
80
|
+
this.doneVM.addItem(task);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
protected onDispose() {
|
|
84
|
+
console.log('TaskTransferController disposed');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Usage ---
|
|
89
|
+
|
|
90
|
+
const todoVM = new TodoListViewModel({
|
|
91
|
+
items: [
|
|
92
|
+
{ id: '1', title: 'Learn mvc-kit', status: 'todo' },
|
|
93
|
+
{ id: '2', title: 'Build an app', status: 'todo' },
|
|
94
|
+
{ id: '3', title: 'Write tests', status: 'todo' },
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
todoVM.init();
|
|
98
|
+
|
|
99
|
+
const doneVM = new DoneListViewModel({ items: [] });
|
|
100
|
+
doneVM.init();
|
|
101
|
+
|
|
102
|
+
const controller = new TaskTransferController(todoVM, doneVM);
|
|
103
|
+
controller.init();
|
|
104
|
+
|
|
105
|
+
console.log('Todo:', todoVM.state.items.length); // 3
|
|
106
|
+
console.log('Done:', doneVM.state.items.length); // 0
|
|
107
|
+
|
|
108
|
+
// Controller coordinates the move
|
|
109
|
+
controller.completeTask('1');
|
|
110
|
+
|
|
111
|
+
console.log('Todo:', todoVM.state.items.length); // 2
|
|
112
|
+
console.log('Done:', doneVM.state.items.length); // 1
|
|
113
|
+
console.log('Done item:', doneVM.state.items[0]?.title); // 'Learn mvc-kit'
|
|
114
|
+
|
|
115
|
+
// Cleanup
|
|
116
|
+
controller.dispose();
|
|
117
|
+
todoVM.dispose();
|
|
118
|
+
doneVM.dispose();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { ViewModel, singleton, hasSingleton, teardown, teardownAll } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// ViewModel: Reactive state + computed getters + async tracking + typed events
|
|
4
|
+
//
|
|
5
|
+
// The core building block. Holds state, derives computed values via getters,
|
|
6
|
+
// provides actions to update state, and tracks async method loading/error
|
|
7
|
+
// automatically. After init(), getters are auto-memoized and only recompute
|
|
8
|
+
// when their dependencies change.
|
|
9
|
+
|
|
10
|
+
// Define state interface
|
|
11
|
+
interface CounterState {
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Create a ViewModel by extending the base class
|
|
16
|
+
class CounterViewModel extends ViewModel<CounterState> {
|
|
17
|
+
// --- Computed getters (auto-memoized after init) ---
|
|
18
|
+
get doubled(): number {
|
|
19
|
+
return this.state.count * 2;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get isPositive(): boolean {
|
|
23
|
+
return this.state.count > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get parity(): 'even' | 'odd' {
|
|
27
|
+
return this.state.count % 2 === 0 ? 'even' : 'odd';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- Actions ---
|
|
31
|
+
increment() {
|
|
32
|
+
this.set({ count: this.state.count + 1 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
decrement() {
|
|
36
|
+
this.set({ count: this.state.count - 1 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
reset() {
|
|
40
|
+
this.set({ count: 0 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Updater function pattern for derived updates
|
|
44
|
+
multiplyBy(factor: number) {
|
|
45
|
+
this.set(prev => ({ count: prev.count * factor }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Lifecycle hook: called after each state update
|
|
49
|
+
protected onSet(prev: Readonly<CounterState>, next: Readonly<CounterState>) {
|
|
50
|
+
console.log(`Count changed: ${prev.count} → ${next.count}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Lifecycle hook: called when disposed
|
|
54
|
+
protected onDispose() {
|
|
55
|
+
console.log('CounterViewModel disposed');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Basic usage ---
|
|
60
|
+
|
|
61
|
+
const counter = new CounterViewModel({ count: 0 });
|
|
62
|
+
counter.init(); // activates getter memoization and async tracking
|
|
63
|
+
|
|
64
|
+
// Subscribe to state changes
|
|
65
|
+
const unsubscribe = counter.subscribe((state, prev) => {
|
|
66
|
+
console.log(`Subscriber notified: ${prev.count} → ${state.count}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
counter.increment(); // Count changed: 0 → 1
|
|
70
|
+
counter.increment(); // Count changed: 1 → 2
|
|
71
|
+
counter.multiplyBy(5); // Count changed: 2 → 10
|
|
72
|
+
|
|
73
|
+
// Access current state
|
|
74
|
+
console.log('Current count:', counter.state.count); // 10
|
|
75
|
+
|
|
76
|
+
// --- Computed getters ---
|
|
77
|
+
|
|
78
|
+
console.log('Doubled:', counter.doubled); // 20
|
|
79
|
+
console.log('Is positive:', counter.isPositive); // true
|
|
80
|
+
console.log('Parity:', counter.parity); // even
|
|
81
|
+
|
|
82
|
+
counter.decrement(); // Count changed: 10 → 9
|
|
83
|
+
console.log('Doubled:', counter.doubled); // 18
|
|
84
|
+
console.log('Parity:', counter.parity); // odd
|
|
85
|
+
|
|
86
|
+
// Cleanup
|
|
87
|
+
unsubscribe();
|
|
88
|
+
counter.dispose();
|
|
89
|
+
|
|
90
|
+
// --- Singleton pattern ---
|
|
91
|
+
|
|
92
|
+
// Get or create a singleton instance
|
|
93
|
+
const shared1 = singleton(CounterViewModel, { count: 100 });
|
|
94
|
+
shared1.init(); // activate getter memoization on the singleton too
|
|
95
|
+
const shared2 = singleton(CounterViewModel, { count: 999 }); // args ignored, same instance returned
|
|
96
|
+
|
|
97
|
+
console.log('Same instance:', shared1 === shared2); // true
|
|
98
|
+
console.log('Singleton count:', shared1.state.count); // 100
|
|
99
|
+
|
|
100
|
+
// Check if singleton exists
|
|
101
|
+
console.log('Has singleton:', hasSingleton(CounterViewModel)); // true
|
|
102
|
+
|
|
103
|
+
// Cleanup singleton
|
|
104
|
+
teardown(CounterViewModel);
|
|
105
|
+
console.log('Has singleton after teardown:', hasSingleton(CounterViewModel)); // false
|
|
106
|
+
|
|
107
|
+
// Cleanup all singletons (useful in tests)
|
|
108
|
+
teardownAll();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare const __MVC_KIT_DEV__: boolean;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { EventBus } from 'mvc-kit';
|
|
2
|
+
|
|
3
|
+
// EventBus: Typed pub/sub for decoupled communication
|
|
4
|
+
|
|
5
|
+
// Define event types
|
|
6
|
+
interface AppEvents {
|
|
7
|
+
'user:login': { userId: string; timestamp: number };
|
|
8
|
+
'user:logout': { userId: string };
|
|
9
|
+
'notification': { message: string; type: 'info' | 'warning' | 'error' };
|
|
10
|
+
'cart:updated': { itemCount: number };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// --- Basic usage ---
|
|
14
|
+
|
|
15
|
+
const bus = new EventBus<AppEvents>();
|
|
16
|
+
|
|
17
|
+
// Subscribe to events
|
|
18
|
+
const unsubLogin = bus.on('user:login', ({ userId, timestamp }) => {
|
|
19
|
+
console.log(`User ${userId} logged in at ${new Date(timestamp).toISOString()}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const unsubNotification = bus.on('notification', ({ message, type }) => {
|
|
23
|
+
console.log(`[${type.toUpperCase()}] ${message}`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Emit events
|
|
27
|
+
bus.emit('user:login', { userId: '123', timestamp: Date.now() });
|
|
28
|
+
bus.emit('notification', { message: 'Welcome back!', type: 'info' });
|
|
29
|
+
|
|
30
|
+
// One-time subscription - auto-unsubscribes after first event
|
|
31
|
+
bus.once('user:logout', ({ userId }) => {
|
|
32
|
+
console.log(`User ${userId} logged out (one-time handler)`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
bus.emit('user:logout', { userId: '123' }); // Handler called
|
|
36
|
+
bus.emit('user:logout', { userId: '123' }); // Handler NOT called (already unsubscribed)
|
|
37
|
+
|
|
38
|
+
// Unsubscribe manually
|
|
39
|
+
unsubLogin();
|
|
40
|
+
unsubNotification();
|
|
41
|
+
|
|
42
|
+
// After unsubscribe, handlers are not called
|
|
43
|
+
bus.emit('user:login', { userId: '456', timestamp: Date.now() }); // No output
|
|
44
|
+
|
|
45
|
+
// --- Practical pattern: Cross-component communication ---
|
|
46
|
+
|
|
47
|
+
class AuthService {
|
|
48
|
+
constructor(private bus: EventBus<AppEvents>) {}
|
|
49
|
+
|
|
50
|
+
login(userId: string) {
|
|
51
|
+
// ... authentication logic ...
|
|
52
|
+
this.bus.emit('user:login', { userId, timestamp: Date.now() });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
logout(userId: string) {
|
|
56
|
+
// ... logout logic ...
|
|
57
|
+
this.bus.emit('user:logout', { userId });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
class NotificationManager {
|
|
62
|
+
constructor(private bus: EventBus<AppEvents>) {
|
|
63
|
+
// React to login events
|
|
64
|
+
this.bus.on('user:login', () => {
|
|
65
|
+
this.bus.emit('notification', { message: 'Welcome!', type: 'info' });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Components can communicate without direct references
|
|
71
|
+
const auth = new AuthService(bus);
|
|
72
|
+
new NotificationManager(bus);
|
|
73
|
+
|
|
74
|
+
auth.login('789'); // Triggers login event, which triggers notification
|
|
75
|
+
|
|
76
|
+
// Cleanup
|
|
77
|
+
bus.dispose();
|