mvc-kit 2.12.0 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/agent-config/bin/postinstall.mjs +5 -3
  2. package/agent-config/bin/setup.mjs +3 -4
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
  4. package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
  5. package/agent-config/lib/install-claude.mjs +10 -33
  6. package/dist/Model.cjs +9 -1
  7. package/dist/Model.cjs.map +1 -1
  8. package/dist/Model.d.ts +1 -1
  9. package/dist/Model.d.ts.map +1 -1
  10. package/dist/Model.js +9 -1
  11. package/dist/Model.js.map +1 -1
  12. package/dist/ViewModel.cjs +9 -1
  13. package/dist/ViewModel.cjs.map +1 -1
  14. package/dist/ViewModel.d.ts +1 -1
  15. package/dist/ViewModel.d.ts.map +1 -1
  16. package/dist/ViewModel.js +9 -1
  17. package/dist/ViewModel.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/mvc-kit.cjs +3 -0
  21. package/dist/mvc-kit.cjs.map +1 -1
  22. package/dist/mvc-kit.js +3 -0
  23. package/dist/mvc-kit.js.map +1 -1
  24. package/dist/produceDraft.cjs +105 -0
  25. package/dist/produceDraft.cjs.map +1 -0
  26. package/dist/produceDraft.d.ts +19 -0
  27. package/dist/produceDraft.d.ts.map +1 -0
  28. package/dist/produceDraft.js +105 -0
  29. package/dist/produceDraft.js.map +1 -0
  30. package/package.json +4 -2
  31. package/src/Channel.md +408 -0
  32. package/src/Channel.test.ts +957 -0
  33. package/src/Channel.ts +429 -0
  34. package/src/Collection.md +533 -0
  35. package/src/Collection.test.ts +1559 -0
  36. package/src/Collection.ts +653 -0
  37. package/src/Controller.md +306 -0
  38. package/src/Controller.test.ts +380 -0
  39. package/src/Controller.ts +90 -0
  40. package/src/EventBus.md +308 -0
  41. package/src/EventBus.test.ts +295 -0
  42. package/src/EventBus.ts +110 -0
  43. package/src/Feed.md +218 -0
  44. package/src/Feed.test.ts +442 -0
  45. package/src/Feed.ts +101 -0
  46. package/src/Model.md +524 -0
  47. package/src/Model.test.ts +642 -0
  48. package/src/Model.ts +260 -0
  49. package/src/Pagination.md +168 -0
  50. package/src/Pagination.test.ts +244 -0
  51. package/src/Pagination.ts +92 -0
  52. package/src/Pending.md +380 -0
  53. package/src/Pending.test.ts +1719 -0
  54. package/src/Pending.ts +390 -0
  55. package/src/PersistentCollection.md +183 -0
  56. package/src/PersistentCollection.test.ts +649 -0
  57. package/src/PersistentCollection.ts +375 -0
  58. package/src/Resource.ViewModel.test.ts +503 -0
  59. package/src/Resource.md +239 -0
  60. package/src/Resource.test.ts +786 -0
  61. package/src/Resource.ts +231 -0
  62. package/src/Selection.md +155 -0
  63. package/src/Selection.test.ts +326 -0
  64. package/src/Selection.ts +117 -0
  65. package/src/Service.md +440 -0
  66. package/src/Service.test.ts +241 -0
  67. package/src/Service.ts +72 -0
  68. package/src/Sorting.md +170 -0
  69. package/src/Sorting.test.ts +334 -0
  70. package/src/Sorting.ts +135 -0
  71. package/src/Trackable.md +166 -0
  72. package/src/Trackable.test.ts +236 -0
  73. package/src/Trackable.ts +129 -0
  74. package/src/ViewModel.async.test.ts +813 -0
  75. package/src/ViewModel.derived.test.ts +1583 -0
  76. package/src/ViewModel.md +1111 -0
  77. package/src/ViewModel.test.ts +1236 -0
  78. package/src/ViewModel.ts +800 -0
  79. package/src/bindPublicMethods.test.ts +126 -0
  80. package/src/bindPublicMethods.ts +48 -0
  81. package/src/env.d.ts +5 -0
  82. package/src/errors.test.ts +155 -0
  83. package/src/errors.ts +133 -0
  84. package/src/index.ts +49 -0
  85. package/src/produceDraft.md +90 -0
  86. package/src/produceDraft.test.ts +394 -0
  87. package/src/produceDraft.ts +168 -0
  88. package/src/react/components/CardList.md +97 -0
  89. package/src/react/components/CardList.test.tsx +142 -0
  90. package/src/react/components/CardList.tsx +68 -0
  91. package/src/react/components/DataTable.md +179 -0
  92. package/src/react/components/DataTable.test.tsx +599 -0
  93. package/src/react/components/DataTable.tsx +267 -0
  94. package/src/react/components/InfiniteScroll.md +116 -0
  95. package/src/react/components/InfiniteScroll.test.tsx +218 -0
  96. package/src/react/components/InfiniteScroll.tsx +70 -0
  97. package/src/react/components/types.ts +90 -0
  98. package/src/react/derived.test.tsx +261 -0
  99. package/src/react/guards.ts +24 -0
  100. package/src/react/index.ts +40 -0
  101. package/src/react/provider.test.tsx +143 -0
  102. package/src/react/provider.tsx +55 -0
  103. package/src/react/strict-mode.test.tsx +266 -0
  104. package/src/react/types.ts +25 -0
  105. package/src/react/use-event-bus.md +214 -0
  106. package/src/react/use-event-bus.test.tsx +168 -0
  107. package/src/react/use-event-bus.ts +40 -0
  108. package/src/react/use-instance.md +204 -0
  109. package/src/react/use-instance.test.tsx +350 -0
  110. package/src/react/use-instance.ts +60 -0
  111. package/src/react/use-local.md +457 -0
  112. package/src/react/use-local.rapid-remount.test.tsx +503 -0
  113. package/src/react/use-local.test.tsx +692 -0
  114. package/src/react/use-local.ts +165 -0
  115. package/src/react/use-model.md +364 -0
  116. package/src/react/use-model.test.tsx +394 -0
  117. package/src/react/use-model.ts +161 -0
  118. package/src/react/use-singleton.md +415 -0
  119. package/src/react/use-singleton.test.tsx +296 -0
  120. package/src/react/use-singleton.ts +69 -0
  121. package/src/react/use-subscribe-only.ts +39 -0
  122. package/src/react/use-teardown.md +169 -0
  123. package/src/react/use-teardown.test.tsx +86 -0
  124. package/src/react/use-teardown.ts +27 -0
  125. package/src/react-native/NativeCollection.test.ts +250 -0
  126. package/src/react-native/NativeCollection.ts +138 -0
  127. package/src/react-native/index.ts +1 -0
  128. package/src/singleton.md +310 -0
  129. package/src/singleton.test.ts +204 -0
  130. package/src/singleton.ts +70 -0
  131. package/src/types.ts +70 -0
  132. package/src/walkPrototypeChain.ts +22 -0
  133. package/src/web/IndexedDBCollection.test.ts +235 -0
  134. package/src/web/IndexedDBCollection.ts +66 -0
  135. package/src/web/WebStorageCollection.test.ts +214 -0
  136. package/src/web/WebStorageCollection.ts +116 -0
  137. package/src/web/idb.ts +184 -0
  138. package/src/web/index.ts +2 -0
  139. package/src/wrapAsyncMethods.ts +249 -0
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Trackable } from './Trackable';
3
+ import { ViewModel } from './ViewModel';
4
+ import { teardownAll } from './singleton';
5
+
6
+ // ── Test subclass ──
7
+
8
+ class Counter extends Trackable {
9
+ private _count = 0;
10
+
11
+ get count() { return this._count; }
12
+
13
+ increment() {
14
+ this._count++;
15
+ this.notify();
16
+ }
17
+
18
+ decrement() {
19
+ this._count--;
20
+ this.notify();
21
+ }
22
+
23
+ registerCleanup(fn: () => void) {
24
+ this.addCleanup(fn);
25
+ }
26
+ }
27
+
28
+ class WithOnDispose extends Trackable {
29
+ disposed_hook_called = false;
30
+
31
+ protected onDispose(): void {
32
+ this.disposed_hook_called = true;
33
+ }
34
+ }
35
+
36
+ // ── Tests ──
37
+
38
+ describe('Trackable', () => {
39
+ beforeEach(() => {
40
+ teardownAll();
41
+ });
42
+
43
+ describe('construction', () => {
44
+ it('starts not disposed', () => {
45
+ const t = new Counter();
46
+ expect(t.disposed).toBe(false);
47
+ });
48
+
49
+ it('can be instantiated directly (concrete class)', () => {
50
+ const t = new Trackable();
51
+ expect(t.disposed).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('subscribe / notify', () => {
56
+ it('fires callback on notify', () => {
57
+ const c = new Counter();
58
+ const listener = vi.fn();
59
+ c.subscribe(listener);
60
+ c.increment();
61
+ expect(listener).toHaveBeenCalledTimes(1);
62
+ });
63
+
64
+ it('fires multiple subscribers', () => {
65
+ const c = new Counter();
66
+ const a = vi.fn();
67
+ const b = vi.fn();
68
+ c.subscribe(a);
69
+ c.subscribe(b);
70
+ c.increment();
71
+ expect(a).toHaveBeenCalledTimes(1);
72
+ expect(b).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it('unsubscribe stops notifications', () => {
76
+ const c = new Counter();
77
+ const listener = vi.fn();
78
+ const unsub = c.subscribe(listener);
79
+ c.increment();
80
+ expect(listener).toHaveBeenCalledTimes(1);
81
+ unsub();
82
+ c.increment();
83
+ expect(listener).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it('no-op when no listeners', () => {
87
+ const c = new Counter();
88
+ // Should not throw
89
+ c.increment();
90
+ expect(c.count).toBe(1);
91
+ });
92
+ });
93
+
94
+ describe('dispose', () => {
95
+ it('sets disposed to true', () => {
96
+ const t = new Counter();
97
+ t.dispose();
98
+ expect(t.disposed).toBe(true);
99
+ });
100
+
101
+ it('is idempotent', () => {
102
+ const cleanup = vi.fn();
103
+ const c = new Counter();
104
+ c.registerCleanup(cleanup);
105
+ c.dispose();
106
+ c.dispose();
107
+ expect(cleanup).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it('clears listeners on dispose', () => {
111
+ const c = new Counter();
112
+ const listener = vi.fn();
113
+ c.subscribe(listener);
114
+ c.dispose();
115
+ // notify after dispose should not fire
116
+ // (accessing protected method via subclass for testing)
117
+ c.increment();
118
+ expect(listener).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it('calls onDispose hook', () => {
122
+ const t = new WithOnDispose();
123
+ t.dispose();
124
+ expect(t.disposed_hook_called).toBe(true);
125
+ });
126
+
127
+ it('runs cleanups in registration order', () => {
128
+ const order: number[] = [];
129
+ const c = new Counter();
130
+ c.registerCleanup(() => order.push(1));
131
+ c.registerCleanup(() => order.push(2));
132
+ c.registerCleanup(() => order.push(3));
133
+ c.dispose();
134
+ expect(order).toEqual([1, 2, 3]);
135
+ });
136
+ });
137
+
138
+ describe('addCleanup', () => {
139
+ it('registers and runs on dispose', () => {
140
+ const cleanup = vi.fn();
141
+ const c = new Counter();
142
+ c.registerCleanup(cleanup);
143
+ expect(cleanup).not.toHaveBeenCalled();
144
+ c.dispose();
145
+ expect(cleanup).toHaveBeenCalledTimes(1);
146
+ });
147
+ });
148
+
149
+ describe('disposeSignal', () => {
150
+ it('returns an AbortSignal', () => {
151
+ const t = new Counter();
152
+ expect(t.disposeSignal).toBeInstanceOf(AbortSignal);
153
+ });
154
+
155
+ it('returns the same reference on repeat access', () => {
156
+ const t = new Counter();
157
+ const s1 = t.disposeSignal;
158
+ const s2 = t.disposeSignal;
159
+ expect(s1).toBe(s2);
160
+ });
161
+
162
+ it('is not aborted initially', () => {
163
+ const t = new Counter();
164
+ expect(t.disposeSignal.aborted).toBe(false);
165
+ });
166
+
167
+ it('is aborted after dispose', () => {
168
+ const t = new Counter();
169
+ const signal = t.disposeSignal;
170
+ t.dispose();
171
+ expect(signal.aborted).toBe(true);
172
+ });
173
+
174
+ it('fires abort event on dispose', () => {
175
+ const t = new Counter();
176
+ const handler = vi.fn();
177
+ t.disposeSignal.addEventListener('abort', handler);
178
+ t.dispose();
179
+ expect(handler).toHaveBeenCalledTimes(1);
180
+ });
181
+ });
182
+
183
+ describe('method binding', () => {
184
+ it('auto-binds public methods for point-free usage', () => {
185
+ const c = new Counter();
186
+ const { increment, decrement } = c;
187
+ increment();
188
+ increment();
189
+ decrement();
190
+ expect(c.count).toBe(1);
191
+ });
192
+
193
+ it('auto-binds subscribe and dispose', () => {
194
+ const c = new Counter();
195
+ const { subscribe, dispose } = c;
196
+ const listener = vi.fn();
197
+ subscribe(listener);
198
+ c.increment();
199
+ expect(listener).toHaveBeenCalledTimes(1);
200
+ dispose();
201
+ expect(c.disposed).toBe(true);
202
+ });
203
+ });
204
+
205
+ describe('ViewModel auto-tracking', () => {
206
+ it('invalidates ViewModel getter when Trackable notifies', () => {
207
+ let computeCount = 0;
208
+
209
+ class TestVM extends ViewModel {
210
+ readonly counter = new Counter();
211
+
212
+ get doubled() {
213
+ computeCount++;
214
+ return this.counter.count * 2;
215
+ }
216
+ }
217
+
218
+ const vm = new TestVM();
219
+ vm.init();
220
+
221
+ expect(vm.doubled).toBe(0);
222
+ expect(computeCount).toBe(1);
223
+
224
+ // Cached — no recompute
225
+ expect(vm.doubled).toBe(0);
226
+ expect(computeCount).toBe(1);
227
+
228
+ // Mutate Trackable → invalidates getter
229
+ vm.counter.increment();
230
+ expect(vm.doubled).toBe(2);
231
+ expect(computeCount).toBe(2);
232
+
233
+ vm.dispose();
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,129 @@
1
+ import type { Disposable } from './types';
2
+ import { bindPublicMethods } from './bindPublicMethods';
3
+
4
+ const PROTECTED_KEYS = new Set(['addCleanup', 'notify']);
5
+
6
+ /**
7
+ * Base class for custom reactive objects that integrate with ViewModel's
8
+ * auto-tracking system. Provides subscribable notifications, disposal
9
+ * lifecycle, and automatic method binding.
10
+ *
11
+ * Any object with a `subscribe()` method is auto-tracked by ViewModel
12
+ * getters. Trackable gives you that plus cleanup infrastructure and
13
+ * point-free methods — the same building blocks used by Sorting,
14
+ * Selection, Feed, Pagination, and Pending.
15
+ *
16
+ * Use Trackable when integrating third-party SDKs, custom query objects,
17
+ * or any reactive state that doesn't fit ViewModel's state/getter model.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * class RPCQuery<Data> extends Trackable {
22
+ * private _data: Data | undefined;
23
+ * private _loading = false;
24
+ *
25
+ * get data() { return this._data; }
26
+ * get loading() { return this._loading; }
27
+ *
28
+ * async call(): Promise<void> {
29
+ * this._loading = true;
30
+ * this.notify();
31
+ * this._data = await fetchData();
32
+ * this._loading = false;
33
+ * this.notify();
34
+ * }
35
+ * }
36
+ *
37
+ * // Used as a ViewModel property — auto-tracked:
38
+ * class UsersVM extends ViewModel {
39
+ * readonly users = new RPCQuery<User[]>();
40
+ * get userList() { return this.users.data ?? []; }
41
+ * }
42
+ * ```
43
+ */
44
+ export class Trackable implements Disposable {
45
+ private _listeners: Set<() => void> | null = null;
46
+ private _disposed = false;
47
+ private _abortController: AbortController | null = null;
48
+ private _cleanups: (() => void)[] | null = null;
49
+
50
+ constructor() {
51
+ bindPublicMethods(this, Object.prototype, PROTECTED_KEYS);
52
+ }
53
+
54
+ // ── Disposable interface ──
55
+
56
+ /** Whether this instance has been disposed. */
57
+ get disposed(): boolean {
58
+ return this._disposed;
59
+ }
60
+
61
+ /** AbortSignal that fires when this instance is disposed. Lazily created. */
62
+ get disposeSignal(): AbortSignal {
63
+ if (!this._abortController) {
64
+ this._abortController = new AbortController();
65
+ }
66
+ return this._abortController.signal;
67
+ }
68
+
69
+ /**
70
+ * Tear down the instance: abort the dispose signal, run all registered
71
+ * cleanups, clear subscribers, and call onDispose. Idempotent.
72
+ */
73
+ dispose(): void {
74
+ if (this._disposed) return;
75
+ this._disposed = true;
76
+ this._abortController?.abort();
77
+ if (this._cleanups) {
78
+ for (const fn of this._cleanups) fn();
79
+ this._cleanups = null;
80
+ }
81
+ this._listeners?.clear();
82
+ this.onDispose?.();
83
+ }
84
+
85
+ // ── Subscribable (notification-only, no state) ──
86
+
87
+ /**
88
+ * Subscribe to change notifications. The callback is invoked (with no
89
+ * arguments) whenever the subclass calls {@link notify}.
90
+ *
91
+ * This is the duck-typed contract that ViewModel's auto-tracking system
92
+ * recognizes — any object with a `subscribe` method is automatically
93
+ * tracked when accessed inside a ViewModel getter.
94
+ *
95
+ * @returns An unsubscribe function.
96
+ */
97
+ subscribe(cb: () => void): () => void {
98
+ if (!this._listeners) this._listeners = new Set();
99
+ this._listeners.add(cb);
100
+ return () => { this._listeners?.delete(cb); };
101
+ }
102
+
103
+ /**
104
+ * Notify all subscribers that state has changed. Call this after
105
+ * mutating internal state to trigger ViewModel getter invalidation
106
+ * and React re-renders.
107
+ * @protected
108
+ */
109
+ protected notify(): void {
110
+ if (this._listeners) {
111
+ for (const cb of this._listeners) cb();
112
+ }
113
+ }
114
+
115
+ // ── Protected utilities ──
116
+
117
+ /**
118
+ * Register a cleanup function to be called on {@link dispose}.
119
+ * Cleanups run in registration order.
120
+ * @protected
121
+ */
122
+ protected addCleanup(fn: () => void): void {
123
+ if (!this._cleanups) this._cleanups = [];
124
+ this._cleanups.push(fn);
125
+ }
126
+
127
+ /** Lifecycle hook called at the end of dispose(). Override for custom teardown. @protected */
128
+ protected onDispose?(): void;
129
+ }