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,334 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { Sorting } from './Sorting';
3
+ import type { SortDescriptor } from './Sorting';
4
+ import { ViewModel } from './ViewModel';
5
+
6
+ // ============================================================================
7
+ // Sorting unit tests
8
+ // ============================================================================
9
+
10
+ describe('Sorting', () => {
11
+ describe('construction', () => {
12
+ it('defaults to empty sorts', () => {
13
+ const s = new Sorting();
14
+ expect(s.sorts).toEqual([]);
15
+ expect(s.key).toBe(null);
16
+ expect(s.direction).toBe('asc');
17
+ });
18
+
19
+ it('accepts initial sorts', () => {
20
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
21
+ expect(s.sorts).toEqual([{ key: 'name', direction: 'asc' }]);
22
+ expect(s.key).toBe('name');
23
+ expect(s.direction).toBe('asc');
24
+ });
25
+
26
+ it('defensively copies initial sorts', () => {
27
+ const initial: SortDescriptor[] = [{ key: 'name', direction: 'asc' }];
28
+ const s = new Sorting({ sorts: initial });
29
+ initial[0].key = 'mutated';
30
+ expect(s.sorts[0].key).toBe('name');
31
+ });
32
+ });
33
+
34
+ describe('3-click toggle cycle', () => {
35
+ it('cycles: not sorted → asc → desc → removed', () => {
36
+ const s = new Sorting();
37
+ const listener = vi.fn();
38
+ s.subscribe(listener);
39
+
40
+ // Not sorted → asc
41
+ s.toggle('name');
42
+ expect(s.sorts).toEqual([{ key: 'name', direction: 'asc' }]);
43
+ expect(listener).toHaveBeenCalledTimes(1);
44
+
45
+ // asc → desc
46
+ s.toggle('name');
47
+ expect(s.sorts).toEqual([{ key: 'name', direction: 'desc' }]);
48
+ expect(listener).toHaveBeenCalledTimes(2);
49
+
50
+ // desc → removed
51
+ s.toggle('name');
52
+ expect(s.sorts).toEqual([]);
53
+ expect(listener).toHaveBeenCalledTimes(3);
54
+ });
55
+ });
56
+
57
+ describe('multi-column sorting', () => {
58
+ it('adds columns independently', () => {
59
+ const s = new Sorting();
60
+ s.toggle('dept');
61
+ s.toggle('name');
62
+ expect(s.sorts).toEqual([
63
+ { key: 'dept', direction: 'asc' },
64
+ { key: 'name', direction: 'asc' },
65
+ ]);
66
+ });
67
+
68
+ it('flips second column without affecting first', () => {
69
+ const s = new Sorting();
70
+ s.toggle('dept');
71
+ s.toggle('name');
72
+ s.toggle('name'); // flip name to desc
73
+ expect(s.sorts).toEqual([
74
+ { key: 'dept', direction: 'asc' },
75
+ { key: 'name', direction: 'desc' },
76
+ ]);
77
+ });
78
+
79
+ it('removes second column, keeping first', () => {
80
+ const s = new Sorting();
81
+ s.toggle('dept');
82
+ s.toggle('name');
83
+ s.toggle('name'); // desc
84
+ s.toggle('name'); // remove
85
+ expect(s.sorts).toEqual([{ key: 'dept', direction: 'asc' }]);
86
+ });
87
+ });
88
+
89
+ describe('query methods', () => {
90
+ it('isSorted returns correct values', () => {
91
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
92
+ expect(s.isSorted('name')).toBe(true);
93
+ expect(s.isSorted('age')).toBe(false);
94
+ });
95
+
96
+ it('directionOf returns direction or null', () => {
97
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'desc' }] });
98
+ expect(s.directionOf('name')).toBe('desc');
99
+ expect(s.directionOf('age')).toBe(null);
100
+ });
101
+
102
+ it('indexOf returns index or -1', () => {
103
+ const s = new Sorting({
104
+ sorts: [
105
+ { key: 'dept', direction: 'asc' },
106
+ { key: 'name', direction: 'desc' },
107
+ ],
108
+ });
109
+ expect(s.indexOf('dept')).toBe(0);
110
+ expect(s.indexOf('name')).toBe(1);
111
+ expect(s.indexOf('age')).toBe(-1);
112
+ });
113
+ });
114
+
115
+ describe('setSort / setSorts / reset', () => {
116
+ it('setSort replaces all with single sort', () => {
117
+ const s = new Sorting({
118
+ sorts: [
119
+ { key: 'dept', direction: 'asc' },
120
+ { key: 'name', direction: 'desc' },
121
+ ],
122
+ });
123
+ s.setSort('age', 'asc');
124
+ expect(s.sorts).toEqual([{ key: 'age', direction: 'asc' }]);
125
+ });
126
+
127
+ it('setSorts replaces all', () => {
128
+ const s = new Sorting();
129
+ s.setSorts([
130
+ { key: 'a', direction: 'asc' },
131
+ { key: 'b', direction: 'desc' },
132
+ ]);
133
+ expect(s.sorts).toEqual([
134
+ { key: 'a', direction: 'asc' },
135
+ { key: 'b', direction: 'desc' },
136
+ ]);
137
+ });
138
+
139
+ it('reset clears all sorts', () => {
140
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
141
+ s.reset();
142
+ expect(s.sorts).toEqual([]);
143
+ });
144
+ });
145
+
146
+ describe('apply', () => {
147
+ const items = [
148
+ { name: 'Charlie', age: 30 },
149
+ { name: 'Alice', age: 25 },
150
+ { name: 'Bob', age: 30 },
151
+ ];
152
+
153
+ it('returns items unchanged when no sorts', () => {
154
+ const s = new Sorting();
155
+ expect(s.apply(items)).toEqual(items);
156
+ });
157
+
158
+ it('sorts strings ascending by default', () => {
159
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
160
+ const result = s.apply(items);
161
+ expect(result.map(i => i.name)).toEqual(['Alice', 'Bob', 'Charlie']);
162
+ });
163
+
164
+ it('sorts strings descending', () => {
165
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'desc' }] });
166
+ const result = s.apply(items);
167
+ expect(result.map(i => i.name)).toEqual(['Charlie', 'Bob', 'Alice']);
168
+ });
169
+
170
+ it('sorts numbers', () => {
171
+ const s = new Sorting({ sorts: [{ key: 'age', direction: 'asc' }] });
172
+ const result = s.apply(items);
173
+ expect(result.map(i => i.age)).toEqual([25, 30, 30]);
174
+ });
175
+
176
+ it('applies multi-column sort (primary then secondary)', () => {
177
+ const s = new Sorting({
178
+ sorts: [
179
+ { key: 'age', direction: 'desc' },
180
+ { key: 'name', direction: 'asc' },
181
+ ],
182
+ });
183
+ const result = s.apply(items);
184
+ // age desc first, then name asc within same age
185
+ expect(result.map(i => i.name)).toEqual(['Bob', 'Charlie', 'Alice']);
186
+ });
187
+
188
+ it('uses custom compareFn', () => {
189
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
190
+ const reversed = (a: any, b: any, key: string) => {
191
+ return String(b[key]).localeCompare(String(a[key]));
192
+ };
193
+ const result = s.apply(items, reversed);
194
+ expect(result.map(i => i.name)).toEqual(['Charlie', 'Bob', 'Alice']);
195
+ });
196
+
197
+ it('does not mutate the original array', () => {
198
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
199
+ const original = [...items];
200
+ s.apply(items);
201
+ expect(items).toEqual(original);
202
+ });
203
+
204
+ it('handles null values', () => {
205
+ const data = [
206
+ { name: 'Bob', value: 2 },
207
+ { name: 'Alice', value: null },
208
+ { name: 'Charlie', value: 1 },
209
+ ];
210
+ const s = new Sorting({ sorts: [{ key: 'value', direction: 'asc' }] });
211
+ const result = s.apply(data);
212
+ expect(result.map(i => i.name)).toEqual(['Alice', 'Charlie', 'Bob']);
213
+ });
214
+ });
215
+
216
+ describe('subscribe notifications', () => {
217
+ it('notifies on toggle', () => {
218
+ const s = new Sorting();
219
+ const listener = vi.fn();
220
+ s.subscribe(listener);
221
+ s.toggle('name');
222
+ expect(listener).toHaveBeenCalledTimes(1);
223
+ });
224
+
225
+ it('notifies on setSort', () => {
226
+ const s = new Sorting();
227
+ const listener = vi.fn();
228
+ s.subscribe(listener);
229
+ s.setSort('name', 'asc');
230
+ expect(listener).toHaveBeenCalledTimes(1);
231
+ });
232
+
233
+ it('notifies on setSorts', () => {
234
+ const s = new Sorting();
235
+ const listener = vi.fn();
236
+ s.subscribe(listener);
237
+ s.setSorts([{ key: 'name', direction: 'asc' }]);
238
+ expect(listener).toHaveBeenCalledTimes(1);
239
+ });
240
+
241
+ it('notifies on reset', () => {
242
+ const s = new Sorting({ sorts: [{ key: 'name', direction: 'asc' }] });
243
+ const listener = vi.fn();
244
+ s.subscribe(listener);
245
+ s.reset();
246
+ expect(listener).toHaveBeenCalledTimes(1);
247
+ });
248
+
249
+ it('unsubscribe stops notifications', () => {
250
+ const s = new Sorting();
251
+ const listener = vi.fn();
252
+ const unsub = s.subscribe(listener);
253
+ unsub();
254
+ s.toggle('name');
255
+ expect(listener).not.toHaveBeenCalled();
256
+ });
257
+ });
258
+
259
+ describe('auto-tracking integration', () => {
260
+ it('ViewModel getter invalidates when sorting changes', () => {
261
+ let sortCallCount = 0;
262
+
263
+ class TestVM extends ViewModel {
264
+ readonly sorting = new Sorting<{ name: string }>();
265
+ private _items = [{ name: 'Charlie' }, { name: 'Alice' }, { name: 'Bob' }];
266
+
267
+ get sorted() {
268
+ sortCallCount++;
269
+ return this.sorting.apply(this._items);
270
+ }
271
+ }
272
+
273
+ const vm = new TestVM();
274
+ vm.init();
275
+
276
+ // First access: computes
277
+ expect(vm.sorted.map(i => i.name)).toEqual(['Charlie', 'Alice', 'Bob']);
278
+ expect(sortCallCount).toBe(1);
279
+
280
+ // Cached
281
+ vm.sorted;
282
+ expect(sortCallCount).toBe(1);
283
+
284
+ // Toggle sorting — getter should recompute
285
+ vm.sorting.toggle('name');
286
+ expect(vm.sorted.map(i => i.name)).toEqual(['Alice', 'Bob', 'Charlie']);
287
+ expect(sortCallCount).toBe(2);
288
+
289
+ // Cached again
290
+ vm.sorted;
291
+ expect(sortCallCount).toBe(2);
292
+
293
+ vm.dispose();
294
+ });
295
+
296
+ it('ViewModel subscribe fires when sorting changes', () => {
297
+ class TestVM extends ViewModel {
298
+ readonly sorting = new Sorting();
299
+ }
300
+
301
+ const vm = new TestVM();
302
+ vm.init();
303
+
304
+ const listener = vi.fn();
305
+ vm.subscribe(listener);
306
+
307
+ vm.sorting.toggle('name');
308
+ expect(listener).toHaveBeenCalledTimes(1);
309
+
310
+ vm.dispose();
311
+ });
312
+ });
313
+
314
+ describe('method binding', () => {
315
+ it('destructured methods work point-free', () => {
316
+ const sorting = new Sorting();
317
+ const { toggle, reset, setSort } = sorting;
318
+ toggle('name');
319
+ expect(sorting.sorts).toHaveLength(1);
320
+ expect(sorting.sorts[0].key).toBe('name');
321
+ reset();
322
+ expect(sorting.sorts).toHaveLength(0);
323
+ setSort('age', 'desc');
324
+ expect(sorting.sorts[0].direction).toBe('desc');
325
+ });
326
+
327
+ it('methods work as React-style callbacks', () => {
328
+ const sorting = new Sorting();
329
+ const callback: (key: string) => void = sorting.toggle;
330
+ callback('name');
331
+ expect(sorting.sorts[0].key).toBe('name');
332
+ });
333
+ });
334
+ });
package/src/Sorting.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { Trackable } from './Trackable';
2
+
3
+ /** Describes a single sort column with key and direction. */
4
+ export interface SortDescriptor {
5
+ key: string;
6
+ direction: 'asc' | 'desc';
7
+ }
8
+
9
+ /**
10
+ * Multi-column sort state manager with a comparator pipeline.
11
+ * Maintains an ordered list of sort descriptors and applies them to arrays.
12
+ * Subscribable — auto-tracked when used as a ViewModel property.
13
+ */
14
+ export class Sorting<T = any> extends Trackable {
15
+ private _sorts: readonly SortDescriptor[];
16
+
17
+ constructor(options?: { sorts?: SortDescriptor[] }) {
18
+ super();
19
+ this._sorts = Object.freeze(options?.sorts?.map(s => ({ ...s })) ?? []);
20
+ }
21
+
22
+ // ── Readable state ──
23
+
24
+ /** Current list of active sort descriptors, in priority order. */
25
+ get sorts(): readonly SortDescriptor[] {
26
+ return this._sorts;
27
+ }
28
+
29
+ /** Primary sort key (first descriptor), or null when empty. */
30
+ get key(): string | null {
31
+ return this._sorts.length > 0 ? this._sorts[0].key : null;
32
+ }
33
+
34
+ /** Primary sort direction. Defaults to 'asc' when empty. */
35
+ get direction(): 'asc' | 'desc' {
36
+ return this._sorts.length > 0 ? this._sorts[0].direction : 'asc';
37
+ }
38
+
39
+ // ── Query ──
40
+
41
+ /** Whether the given key is currently sorted. */
42
+ isSorted(key: string): boolean {
43
+ return this._sorts.some(s => s.key === key);
44
+ }
45
+
46
+ /** Returns the sort direction for a key, or null if not sorted. */
47
+ directionOf(key: string): 'asc' | 'desc' | null {
48
+ const found = this._sorts.find(s => s.key === key);
49
+ return found ? found.direction : null;
50
+ }
51
+
52
+ /** Returns the priority index of a sorted key, or -1 if not sorted. */
53
+ indexOf(key: string): number {
54
+ return this._sorts.findIndex(s => s.key === key);
55
+ }
56
+
57
+ // ── Actions ──
58
+
59
+ /** 3-click cycle: not sorted → asc → desc → removed. */
60
+ toggle(key: string): void {
61
+ const idx = this.indexOf(key);
62
+ if (idx === -1) {
63
+ // Add as asc
64
+ this._sorts = Object.freeze([...this._sorts, { key, direction: 'asc' as const }]);
65
+ } else if (this._sorts[idx].direction === 'asc') {
66
+ // Flip to desc
67
+ const next = this._sorts.map((s, i) =>
68
+ i === idx ? { key: s.key, direction: 'desc' as const } : s
69
+ );
70
+ this._sorts = Object.freeze(next);
71
+ } else {
72
+ // Remove
73
+ this._sorts = Object.freeze(this._sorts.filter((_, i) => i !== idx));
74
+ }
75
+ this.notify();
76
+ }
77
+
78
+ /** Replace all with a single sort. */
79
+ setSort(key: string, direction: 'asc' | 'desc'): void {
80
+ this._sorts = Object.freeze([{ key, direction }]);
81
+ this.notify();
82
+ }
83
+
84
+ /** Replace all sorts. */
85
+ setSorts(sorts: SortDescriptor[]): void {
86
+ this._sorts = Object.freeze(sorts.map(s => ({ ...s })));
87
+ this.notify();
88
+ }
89
+
90
+ /** Clear all sort descriptors. */
91
+ reset(): void {
92
+ this._sorts = Object.freeze([]);
93
+ this.notify();
94
+ }
95
+
96
+ // ── Pipeline ──
97
+
98
+ /** Sort an array using the current descriptors. Returns a new sorted array. */
99
+ apply(
100
+ items: T[],
101
+ compareFn?: (a: T, b: T, key: string, dir: 'asc' | 'desc') => number,
102
+ ): T[] {
103
+ if (this._sorts.length === 0) return items;
104
+ const sorted = items.slice();
105
+ const sorts = this._sorts;
106
+ sorted.sort((a, b) => {
107
+ for (const { key, direction } of sorts) {
108
+ let cmp: number;
109
+ if (compareFn) {
110
+ cmp = compareFn(a, b, key, direction);
111
+ } else {
112
+ cmp = defaultCompare(a, b, key);
113
+ }
114
+ if (direction === 'desc') cmp = -cmp;
115
+ if (cmp !== 0) return cmp;
116
+ }
117
+ return 0;
118
+ });
119
+ return sorted;
120
+ }
121
+ }
122
+
123
+ function defaultCompare(a: any, b: any, key: string): number {
124
+ const aVal = a[key];
125
+ const bVal = b[key];
126
+ if (aVal == null && bVal == null) return 0;
127
+ if (aVal == null) return -1;
128
+ if (bVal == null) return 1;
129
+ if (typeof aVal === 'string' && typeof bVal === 'string') {
130
+ return aVal.localeCompare(bVal);
131
+ }
132
+ if (aVal < bVal) return -1;
133
+ if (aVal > bVal) return 1;
134
+ return 0;
135
+ }
@@ -0,0 +1,166 @@
1
+ # Trackable
2
+
3
+ Base class for custom reactive objects that integrate with ViewModel's auto-tracking system. Provides subscribable notifications, disposal lifecycle, cleanup infrastructure, and automatic method binding.
4
+
5
+ ## When to Use
6
+
7
+ Use `Trackable` when building custom classes that need to:
8
+ - **Integrate with ViewModel getters** — any `Trackable` property on a ViewModel is auto-tracked; getter results invalidate when you call `notify()`
9
+ - **Work with `useLocal`** — Trackable objects get automatic React re-renders via version-counter subscription
10
+ - **Manage cleanup** — `addCleanup()` + `disposeSignal` for event listeners, timers, and external subscriptions
11
+ - **Support point-free callbacks** — public methods are auto-bound in the constructor
12
+
13
+ Common use cases: RPC query wrappers, Firebase/Supabase real-time subscriptions, WebSocket message handlers, tRPC query objects, or any third-party SDK integration that manages its own async state.
14
+
15
+ ## When NOT to Use
16
+
17
+ - **Need state + getters + async tracking** → Use `ViewModel`
18
+ - **Need a shared data cache** → Use `Collection` or `Resource`
19
+ - **Need validation + dirty tracking** → Use `Model`
20
+ - **Need stateless orchestration** → Use `Controller`
21
+
22
+ ## API
23
+
24
+ ### Constructor
25
+
26
+ ```ts
27
+ class MyTrackable extends Trackable {
28
+ constructor() {
29
+ super(); // auto-binds all public methods
30
+ }
31
+ }
32
+ ```
33
+
34
+ ### Properties
35
+
36
+ | Property | Type | Description |
37
+ |----------|------|-------------|
38
+ | `disposed` | `boolean` | Whether `dispose()` has been called |
39
+ | `disposeSignal` | `AbortSignal` | Fires when disposed; lazily created |
40
+
41
+ ### Methods
42
+
43
+ | Method | Description |
44
+ |--------|-------------|
45
+ | `subscribe(cb)` | Subscribe to change notifications. Returns unsubscribe function |
46
+ | `dispose()` | Tear down: abort signal, run cleanups, clear listeners, call `onDispose()`. Idempotent |
47
+
48
+ ### Protected Methods
49
+
50
+ | Method | Description |
51
+ |--------|-------------|
52
+ | `notify()` | Notify all subscribers that state changed |
53
+ | `addCleanup(fn)` | Register a function to run on `dispose()` |
54
+ | `onDispose()` | Lifecycle hook called at end of `dispose()`. Override for custom teardown |
55
+
56
+ ## Example: RPC Query Integration
57
+
58
+ ```ts
59
+ import { Trackable } from 'mvc-kit';
60
+
61
+ class RPCQuery<Args, Data> extends Trackable {
62
+ private _data: Data | undefined;
63
+ private _loading = false;
64
+ private _callCounter = 0;
65
+
66
+ get data() { return this._data; }
67
+ get loading() { return this._loading; }
68
+
69
+ constructor(private endpoint: string) {
70
+ super();
71
+ }
72
+
73
+ async call(args?: Args): Promise<Data> {
74
+ const callId = ++this._callCounter;
75
+ this._loading = true;
76
+ this.notify();
77
+
78
+ try {
79
+ const result = await rpcClient.call<Data>(this.endpoint, args);
80
+ if (callId === this._callCounter) {
81
+ this._data = result;
82
+ this._loading = false;
83
+ this.notify();
84
+ }
85
+ return result;
86
+ } catch (err) {
87
+ if (callId === this._callCounter) {
88
+ this._loading = false;
89
+ this.notify();
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+ }
95
+ ```
96
+
97
+ ## ViewModel Integration (Auto-Tracking)
98
+
99
+ ```ts
100
+ class UsersVM extends ViewModel<{ search: string }> {
101
+ readonly users = new RPCQuery<void, User[]>('Users.List');
102
+
103
+ // Auto-tracked — recomputes when users.notify() fires
104
+ get userList() {
105
+ return this.users.data ?? [];
106
+ }
107
+
108
+ get isLoading() {
109
+ return this.users.loading;
110
+ }
111
+
112
+ async onInit() {
113
+ await this.users.call();
114
+ }
115
+ }
116
+ ```
117
+
118
+ ## React Usage (useLocal)
119
+
120
+ Trackable objects work with `useLocal` — the component re-renders when `notify()` fires:
121
+
122
+ ```tsx
123
+ function UserSearch() {
124
+ const query = useLocal(() => new RPCQuery<string, User[]>('Users.Search'));
125
+
126
+ return (
127
+ <div>
128
+ {query.loading && <Spinner />}
129
+ {query.data?.map(user => <UserCard key={user.id} user={user} />)}
130
+ <button onClick={() => query.call('alice')}>Search</button>
131
+ </div>
132
+ );
133
+ }
134
+ ```
135
+
136
+ ## Cleanup with addCleanup
137
+
138
+ ```ts
139
+ class LiveQuery extends Trackable {
140
+ constructor(private channel: string) {
141
+ super();
142
+ const unsub = eventSource.on(channel, () => this.refresh());
143
+ this.addCleanup(unsub); // runs automatically on dispose
144
+
145
+ // Or use disposeSignal for fetch abort
146
+ this.loadInitial();
147
+ }
148
+
149
+ private async loadInitial() {
150
+ await fetch('/api/data', { signal: this.disposeSignal });
151
+ }
152
+ }
153
+ ```
154
+
155
+ ## Method Binding
156
+
157
+ All public methods are auto-bound — safe to pass as callbacks:
158
+
159
+ ```tsx
160
+ const { call, dispose } = query;
161
+ <button onClick={call}>Fetch</button> // works without wrapper
162
+ ```
163
+
164
+ ## Internal Usage
165
+
166
+ `Trackable` is the base class for mvc-kit's composable helpers: `Sorting`, `Selection`, `Feed`, `Pagination`, and `Pending` all extend it.