mvc-kit 2.12.0 → 2.12.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 (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +19 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,241 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { Service } from './Service';
3
+ import { singleton, teardown, teardownAll } from './singleton';
4
+
5
+ class ApiService extends Service {
6
+ onDisposeCalled = false;
7
+
8
+ protected onDispose(): void {
9
+ this.onDisposeCalled = true;
10
+ }
11
+
12
+ async fetchData(): Promise<string> {
13
+ return 'data';
14
+ }
15
+ }
16
+
17
+ class StorageService extends Service {
18
+ private data: Map<string, string> = new Map();
19
+
20
+ set(key: string, value: string): void {
21
+ this.data.set(key, value);
22
+ }
23
+
24
+ get(key: string): string | undefined {
25
+ return this.data.get(key);
26
+ }
27
+
28
+ protected onDispose(): void {
29
+ this.data.clear();
30
+ }
31
+ }
32
+
33
+ describe('Service', () => {
34
+ describe('initialization', () => {
35
+ it('starts not disposed', () => {
36
+ const service = new ApiService();
37
+ expect(service.disposed).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('dispose', () => {
42
+ it('sets disposed to true', () => {
43
+ const service = new ApiService();
44
+ service.dispose();
45
+ expect(service.disposed).toBe(true);
46
+ });
47
+
48
+ it('calls onDispose hook', () => {
49
+ const service = new ApiService();
50
+ expect(service.onDisposeCalled).toBe(false);
51
+ service.dispose();
52
+ expect(service.onDisposeCalled).toBe(true);
53
+ });
54
+
55
+ it('is idempotent', () => {
56
+ let callCount = 0;
57
+ class CountingService extends Service {
58
+ protected onDispose(): void {
59
+ callCount++;
60
+ }
61
+ }
62
+ const service = new CountingService();
63
+ service.dispose();
64
+ service.dispose();
65
+ service.dispose();
66
+ expect(callCount).toBe(1);
67
+ });
68
+ });
69
+
70
+ describe('init', () => {
71
+ it('starts not initialized', () => {
72
+ const service = new ApiService();
73
+ expect(service.initialized).toBe(false);
74
+ });
75
+
76
+ it('sets initialized to true after init()', () => {
77
+ const service = new ApiService();
78
+ service.init();
79
+ expect(service.initialized).toBe(true);
80
+ });
81
+
82
+ it('calls onInit hook', () => {
83
+ let called = false;
84
+ class InitService extends Service {
85
+ protected onInit() {
86
+ called = true;
87
+ }
88
+ }
89
+ const service = new InitService();
90
+ service.init();
91
+ expect(called).toBe(true);
92
+ });
93
+
94
+ it('is idempotent — onInit called only once', () => {
95
+ let callCount = 0;
96
+ class CountingService extends Service {
97
+ protected onInit() {
98
+ callCount++;
99
+ }
100
+ }
101
+ const service = new CountingService();
102
+ service.init();
103
+ service.init();
104
+ service.init();
105
+ expect(callCount).toBe(1);
106
+ });
107
+
108
+ it('supports async onInit', async () => {
109
+ let resolved = false;
110
+ class AsyncService extends Service {
111
+ protected async onInit() {
112
+ await Promise.resolve();
113
+ resolved = true;
114
+ }
115
+ }
116
+ const service = new AsyncService();
117
+ await service.init();
118
+ expect(resolved).toBe(true);
119
+ });
120
+
121
+ it('is a no-op after dispose', () => {
122
+ let called = false;
123
+ class InitService extends Service {
124
+ protected onInit() {
125
+ called = true;
126
+ }
127
+ }
128
+ const service = new InitService();
129
+ service.dispose();
130
+ service.init();
131
+ expect(called).toBe(false);
132
+ expect(service.initialized).toBe(false);
133
+ });
134
+ });
135
+
136
+ describe('methods', () => {
137
+ it('can perform operations', async () => {
138
+ const service = new ApiService();
139
+ const result = await service.fetchData();
140
+ expect(result).toBe('data');
141
+ });
142
+
143
+ it('can manage state', () => {
144
+ const service = new StorageService();
145
+ service.set('key', 'value');
146
+ expect(service.get('key')).toBe('value');
147
+ });
148
+ });
149
+
150
+ describe('singleton integration', () => {
151
+ beforeEach(() => {
152
+ teardownAll();
153
+ });
154
+
155
+ it('can be used with singleton registry', () => {
156
+ const s1 = singleton(ApiService);
157
+ const s2 = singleton(ApiService);
158
+ expect(s1).toBe(s2);
159
+ });
160
+
161
+ it('maintains separate singletons per class', () => {
162
+ const api = singleton(ApiService);
163
+ const storage = singleton(StorageService);
164
+ expect(api).not.toBe(storage);
165
+ });
166
+
167
+ it('can be torn down', () => {
168
+ const service = singleton(ApiService);
169
+ teardown(ApiService);
170
+ expect(service.disposed).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe('signal and addCleanup', () => {
175
+ it('signal returns an AbortSignal', () => {
176
+ const service = new ApiService();
177
+ expect(service.disposeSignal).toBeInstanceOf(AbortSignal);
178
+ });
179
+
180
+ it('returns the same signal on multiple accesses', () => {
181
+ const service = new ApiService();
182
+ const s1 = service.disposeSignal;
183
+ const s2 = service.disposeSignal;
184
+ expect(s1).toBe(s2);
185
+ });
186
+
187
+ it('signal is not aborted before dispose', () => {
188
+ const service = new ApiService();
189
+ expect(service.disposeSignal.aborted).toBe(false);
190
+ });
191
+
192
+ it('signal is aborted after dispose', () => {
193
+ const service = new ApiService();
194
+ const signal = service.disposeSignal;
195
+ service.dispose();
196
+ expect(signal.aborted).toBe(true);
197
+ });
198
+
199
+ it('signal is aborted before onDispose runs', () => {
200
+ let wasAbortedDuringDispose = false;
201
+ class CheckService extends Service {
202
+ protected onDispose(): void {
203
+ wasAbortedDuringDispose = this.disposeSignal.aborted;
204
+ }
205
+ }
206
+ const service = new CheckService();
207
+ service.disposeSignal; // force lazy creation
208
+ service.dispose();
209
+ expect(wasAbortedDuringDispose).toBe(true);
210
+ });
211
+
212
+ it('addCleanup fires on dispose', () => {
213
+ let cleaned = false;
214
+ class CleanupService extends Service {
215
+ setup() {
216
+ this.addCleanup(() => { cleaned = true; });
217
+ }
218
+ }
219
+ const service = new CleanupService();
220
+ service.setup();
221
+ expect(cleaned).toBe(false);
222
+ service.dispose();
223
+ expect(cleaned).toBe(true);
224
+ });
225
+
226
+ it('dispose works without accessing signal (lazy, zero cost)', () => {
227
+ const service = new ApiService();
228
+ service.dispose();
229
+ expect(service.disposed).toBe(true);
230
+ });
231
+ });
232
+
233
+ describe('method binding', () => {
234
+ it('subclass methods work point-free', () => {
235
+ const svc = new StorageService();
236
+ const { set, get } = svc;
237
+ set('key', 'value');
238
+ expect(get('key')).toBe('value');
239
+ });
240
+ });
241
+ });
package/src/Service.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { Disposable } from './types';
2
+ import { bindPublicMethods } from './bindPublicMethods';
3
+
4
+ const PROTECTED_KEYS = new Set(['addCleanup']);
5
+
6
+ /**
7
+ * Base class for non-reactive infrastructure services.
8
+ * Services encapsulate external dependencies like APIs, storage, etc.
9
+ */
10
+ export abstract class Service implements Disposable {
11
+ private _disposed = false;
12
+ private _initialized = false;
13
+ private _abortController: AbortController | null = null;
14
+ private _cleanups: (() => void)[] | null = null;
15
+
16
+ constructor() {
17
+ bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
18
+ }
19
+
20
+ /** Whether this instance has been disposed. */
21
+ get disposed(): boolean {
22
+ return this._disposed;
23
+ }
24
+
25
+ /** Whether init() has been called. */
26
+ get initialized(): boolean {
27
+ return this._initialized;
28
+ }
29
+
30
+ /** AbortSignal that fires when this instance is disposed. Lazily created. */
31
+ get disposeSignal(): AbortSignal {
32
+ if (!this._abortController) {
33
+ this._abortController = new AbortController();
34
+ }
35
+ return this._abortController.signal;
36
+ }
37
+
38
+ /** Initializes the instance. Called automatically by React hooks after mount. */
39
+ init(): void | Promise<void> {
40
+ if (this._initialized || this._disposed) return;
41
+ this._initialized = true;
42
+ return this.onInit?.();
43
+ }
44
+
45
+ /** Tears down the instance, releasing all subscriptions and resources. */
46
+ dispose(): void {
47
+ if (this._disposed) {
48
+ return;
49
+ }
50
+
51
+ this._disposed = true;
52
+ this._abortController?.abort();
53
+ if (this._cleanups) {
54
+ for (const fn of this._cleanups) fn();
55
+ this._cleanups = null;
56
+ }
57
+ this.onDispose?.();
58
+ }
59
+
60
+ /** Registers a cleanup function to be called on dispose. @protected */
61
+ protected addCleanup(fn: () => void): void {
62
+ if (!this._cleanups) {
63
+ this._cleanups = [];
64
+ }
65
+ this._cleanups.push(fn);
66
+ }
67
+
68
+ /** Lifecycle hook called at the end of init(). Override to load initial data. @protected */
69
+ protected onInit?(): void | Promise<void>;
70
+ /** Lifecycle hook called during dispose(). Override for custom teardown. @protected */
71
+ protected onDispose?(): void;
72
+ }
package/src/Sorting.md ADDED
@@ -0,0 +1,170 @@
1
+ # Sorting
2
+
3
+ Multi-column sorting helper. Manages sort descriptors with a 3-click toggle cycle and applies sorting to arrays. Extends [`Trackable`](./Trackable.md) — subscribable, disposable, and auto-bound.
4
+
5
+ ---
6
+
7
+ ## When to Use
8
+
9
+ Use Sorting as a ViewModel property to manage table/list sort state. Auto-tracking ensures ViewModel getters that read from Sorting auto-invalidate when sort state changes.
10
+
11
+ ---
12
+
13
+ ## Creating a Sorting Instance
14
+
15
+ ```typescript
16
+ import { Sorting } from 'mvc-kit';
17
+
18
+ // No initial sorting
19
+ readonly sorting = new Sorting<User>();
20
+
21
+ // With initial sort
22
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
23
+ ```
24
+
25
+ The generic parameter `<T>` is the type of items you'll sort. It's used by `apply()` and `compareFn`.
26
+
27
+ ---
28
+
29
+ ## API
30
+
31
+ ### Readable State
32
+
33
+ #### `sorts: readonly SortDescriptor[]`
34
+
35
+ Full list of active sort descriptors, in priority order.
36
+
37
+ ```typescript
38
+ interface SortDescriptor {
39
+ key: string;
40
+ direction: 'asc' | 'desc';
41
+ }
42
+ ```
43
+
44
+ #### `key: string | null`
45
+
46
+ Primary sort key (first descriptor), or `null` when empty.
47
+
48
+ #### `direction: 'asc' | 'desc'`
49
+
50
+ Primary sort direction. Defaults to `'asc'` when empty.
51
+
52
+ ### Query Methods
53
+
54
+ #### `isSorted(key: string): boolean`
55
+
56
+ Whether the given key has an active sort descriptor.
57
+
58
+ #### `directionOf(key: string): 'asc' | 'desc' | null`
59
+
60
+ Direction of the given key, or `null` if not sorted.
61
+
62
+ #### `indexOf(key: string): number`
63
+
64
+ Index of the key in the sort list, or `-1` if not sorted.
65
+
66
+ ### Actions
67
+
68
+ #### `toggle(key: string): void`
69
+
70
+ 3-click cycle: **not sorted -> asc -> desc -> removed**.
71
+
72
+ ```typescript
73
+ sorting.toggle('name'); // [{key:'name', dir:'asc'}]
74
+ sorting.toggle('name'); // [{key:'name', dir:'desc'}]
75
+ sorting.toggle('name'); // [] (removed)
76
+ ```
77
+
78
+ Multi-column: toggling a new key appends it. Toggling an existing key advances its cycle without affecting others.
79
+
80
+ #### `setSort(key: string, direction: 'asc' | 'desc'): void`
81
+
82
+ Replace all sorts with a single sort descriptor.
83
+
84
+ #### `setSorts(sorts: SortDescriptor[]): void`
85
+
86
+ Replace all sorts with the given descriptors.
87
+
88
+ #### `reset(): void`
89
+
90
+ Clear all sort descriptors.
91
+
92
+ ### Pipeline
93
+
94
+ #### `apply(items: T[], compareFn?): T[]`
95
+
96
+ Returns a new sorted array (does not mutate the input).
97
+
98
+ Default comparison: `localeCompare` for strings, `<`/`>` for others, `null` values sort first.
99
+
100
+ Custom `compareFn` signature:
101
+
102
+ ```typescript
103
+ (a: T, b: T, key: string, dir: 'asc' | 'desc') => number
104
+ ```
105
+
106
+ ### Subscribable Interface
107
+
108
+ #### `subscribe(cb: () => void): () => void`
109
+
110
+ Subscribe to state changes. Returns an unsubscribe function.
111
+
112
+ ---
113
+
114
+ ## ViewModel Integration
115
+
116
+ Declare as a ViewModel instance property. Auto-tracking handles the rest:
117
+
118
+ ```typescript
119
+ class UsersVM extends ViewModel<FilterState> {
120
+ readonly sorting = new Sorting<User>({ sorts: [{ key: 'name', direction: 'asc' }] });
121
+ private users = singleton(UsersResource);
122
+
123
+ get sorted(): User[] {
124
+ // Auto-invalidates when sorting changes
125
+ return this.sorting.apply(this.users.items);
126
+ }
127
+ }
128
+ ```
129
+
130
+ ---
131
+
132
+ ## React Usage
133
+
134
+ Pass the Sorting helper directly to DataTable:
135
+
136
+ ```tsx
137
+ <DataTable
138
+ items={vm.items}
139
+ columns={columns}
140
+ sort={vm.sorting}
141
+ />
142
+ ```
143
+
144
+ Or use the object-literal form for custom usage:
145
+
146
+ ```tsx
147
+ <DataTable
148
+ items={vm.items}
149
+ columns={columns}
150
+ sort={vm.sorting.sorts}
151
+ onSort={key => vm.sorting.toggle(key)}
152
+ />
153
+ ```
154
+
155
+ Or with plain buttons:
156
+
157
+ ```tsx
158
+ <button onClick={() => vm.sorting.toggle('name')}>
159
+ Name {vm.sorting.directionOf('name') === 'asc' ? '↑' : vm.sorting.directionOf('name') === 'desc' ? '↓' : ''}
160
+ </button>
161
+ ```
162
+
163
+ ## Method Binding
164
+
165
+ All public methods are auto-bound in the constructor. You can pass them point-free as callbacks without losing `this` context:
166
+
167
+ ```tsx
168
+ const { toggle } = sorting; // destructure works
169
+ <SortHeader onClick={toggle} /> // point-free works
170
+ ```