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,126 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { bindPublicMethods } from './bindPublicMethods';
3
+
4
+ class Base {
5
+ value = 0;
6
+ increment() { this.value++; }
7
+ _private() { this.value += 100; }
8
+ get computed() { return this.value * 2; }
9
+ }
10
+
11
+ class Child extends Base {
12
+ greet() { return `hello ${this.value}`; }
13
+ increment() { this.value += 10; } // override
14
+ }
15
+
16
+ describe('bindPublicMethods', () => {
17
+ it('binds public methods so they work detached', () => {
18
+ const obj = new Base();
19
+ bindPublicMethods(obj);
20
+ const { increment } = obj;
21
+ increment();
22
+ expect(obj.value).toBe(1);
23
+ });
24
+
25
+ it('skips _-prefixed methods', () => {
26
+ const obj = new Base();
27
+ bindPublicMethods(obj);
28
+ // _private should NOT be an own property (still on prototype)
29
+ expect(Object.getOwnPropertyDescriptor(obj, '_private')?.value).toBeUndefined();
30
+ });
31
+
32
+ it('skips getters', () => {
33
+ const obj = new Base();
34
+ bindPublicMethods(obj);
35
+ // computed should still be a getter, not a bound function
36
+ expect(obj.computed).toBe(0);
37
+ obj.value = 5;
38
+ expect(obj.computed).toBe(10);
39
+ });
40
+
41
+ it('most-derived override wins in inheritance chains', () => {
42
+ const obj = new Child();
43
+ bindPublicMethods(obj);
44
+ const { increment } = obj;
45
+ increment(); // should use Child's override (+10), not Base's (+1)
46
+ expect(obj.value).toBe(10);
47
+ });
48
+
49
+ it('binds subclass methods', () => {
50
+ const obj = new Child();
51
+ bindPublicMethods(obj);
52
+ const { greet } = obj;
53
+ expect(greet()).toBe('hello 0');
54
+ });
55
+
56
+ it('is a no-op for classes with no public methods', () => {
57
+ class Empty {
58
+ _internal() {}
59
+ }
60
+ const obj = new Empty();
61
+ bindPublicMethods(obj);
62
+ // No own method properties should be added
63
+ const ownKeys = Object.getOwnPropertyNames(obj).filter(
64
+ k => typeof (obj as any)[k] === 'function',
65
+ );
66
+ expect(ownKeys).toHaveLength(0);
67
+ });
68
+
69
+ it('stopPrototype limits which prototypes are walked', () => {
70
+ class GrandParent {
71
+ gpMethod() { return 'gp'; }
72
+ }
73
+ class Parent extends GrandParent {
74
+ parentMethod() { return 'parent'; }
75
+ }
76
+ class Leaf extends Parent {
77
+ leafMethod() { return 'leaf'; }
78
+ }
79
+
80
+ const obj = new Leaf();
81
+ // Stop at GrandParent.prototype — should NOT bind gpMethod
82
+ bindPublicMethods(obj, GrandParent.prototype);
83
+ const { leafMethod, parentMethod } = obj;
84
+ expect(leafMethod()).toBe('leaf');
85
+ expect(parentMethod()).toBe('parent');
86
+ // gpMethod should NOT be an own property
87
+ expect(Object.getOwnPropertyDescriptor(obj, 'gpMethod')).toBeUndefined();
88
+ });
89
+
90
+ it('caches results across instances of the same class', () => {
91
+ const obj1 = new Child();
92
+ const obj2 = new Child();
93
+ bindPublicMethods(obj1);
94
+ bindPublicMethods(obj2);
95
+ // Both should work — cache serves second instance
96
+ const { increment: inc1 } = obj1;
97
+ const { increment: inc2 } = obj2;
98
+ inc1();
99
+ inc2();
100
+ expect(obj1.value).toBe(10);
101
+ expect(obj2.value).toBe(10);
102
+ // They should be independent bindings
103
+ expect(obj1.increment).not.toBe(obj2.increment);
104
+ });
105
+
106
+ it('exclude parameter skips specified method names', () => {
107
+ class WithProtected {
108
+ value = 0;
109
+ publicMethod() { this.value = 1; }
110
+ protectedMethod() { this.value = 99; }
111
+ anotherProtected() { this.value = 99; }
112
+ }
113
+ const exclude = new Set(['protectedMethod', 'anotherProtected']);
114
+ const obj = new WithProtected();
115
+ bindPublicMethods(obj, Object.prototype, exclude);
116
+
117
+ // publicMethod should be bound (own property)
118
+ const { publicMethod } = obj;
119
+ publicMethod();
120
+ expect(obj.value).toBe(1);
121
+
122
+ // excluded methods should NOT be own properties
123
+ expect(Object.getOwnPropertyDescriptor(obj, 'protectedMethod')).toBeUndefined();
124
+ expect(Object.getOwnPropertyDescriptor(obj, 'anotherProtected')).toBeUndefined();
125
+ });
126
+ });
@@ -0,0 +1,48 @@
1
+ import { walkPrototypeChain } from './walkPrototypeChain';
2
+
3
+ /** Cached list of bindable method keys + prototype functions per class. */
4
+ const methodCache = new WeakMap<Function, Array<{ key: string; fn: Function }>>();
5
+
6
+ /**
7
+ * Auto-binds all public methods on the instance so they can be passed
8
+ * as callbacks without losing `this` context (point-free style).
9
+ *
10
+ * Walks the prototype chain from the instance up to (but not including)
11
+ * `stopPrototype` (defaults to `Object.prototype`). Skips `_`-prefixed
12
+ * methods, getters/setters, and any keys in the `exclude` set.
13
+ * Most-derived version wins for override chains.
14
+ *
15
+ * Results are cached per class constructor in a WeakMap — subsequent
16
+ * instances of the same class skip the prototype walk entirely.
17
+ *
18
+ * @param exclude Keys to skip (e.g. protected/internal methods that should
19
+ * not be bound). Must be the same set for all instances of a given class
20
+ * since the result is cached by constructor.
21
+ */
22
+ export function bindPublicMethods(
23
+ instance: object,
24
+ stopPrototype: object = Object.prototype,
25
+ exclude?: ReadonlySet<string>,
26
+ ): void {
27
+ const ctor = instance.constructor;
28
+ let methods = methodCache.get(ctor);
29
+
30
+ if (!methods) {
31
+ methods = [];
32
+ const seen = new Set<string>();
33
+ walkPrototypeChain(instance, stopPrototype, (key, desc) => {
34
+ if (seen.has(key)) return;
35
+ seen.add(key);
36
+ if (desc.get || desc.set) return;
37
+ if (typeof desc.value !== 'function') return;
38
+ if (key.startsWith('_')) return;
39
+ if (exclude?.has(key)) return;
40
+ methods!.push({ key, fn: desc.value });
41
+ });
42
+ methodCache.set(ctor, methods);
43
+ }
44
+
45
+ for (let i = 0; i < methods.length; i++) {
46
+ (instance as any)[methods[i].key] = methods[i].fn.bind(instance);
47
+ }
48
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare global {
2
+ var __MVC_KIT_DEV__: boolean | undefined;
3
+ }
4
+
5
+ export {};
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { HttpError, isAbortError, classifyError } from './errors';
3
+ import type { AppError } from './errors';
4
+
5
+ describe('HttpError', () => {
6
+ it('creates with status and default message', () => {
7
+ const err = new HttpError(404);
8
+ expect(err.status).toBe(404);
9
+ expect(err.message).toBe('HTTP 404');
10
+ expect(err.name).toBe('HttpError');
11
+ expect(err).toBeInstanceOf(Error);
12
+ });
13
+
14
+ it('creates with status and custom message', () => {
15
+ const err = new HttpError(401, 'Unauthorized');
16
+ expect(err.status).toBe(401);
17
+ expect(err.message).toBe('Unauthorized');
18
+ });
19
+ });
20
+
21
+ describe('isAbortError', () => {
22
+ it('returns true for AbortError DOMException', () => {
23
+ const err = new DOMException('The operation was aborted', 'AbortError');
24
+ expect(isAbortError(err)).toBe(true);
25
+ });
26
+
27
+ it('returns false for other DOMExceptions', () => {
28
+ const err = new DOMException('msg', 'NotFoundError');
29
+ expect(isAbortError(err)).toBe(false);
30
+ });
31
+
32
+ it('returns false for regular Error', () => {
33
+ expect(isAbortError(new Error('AbortError'))).toBe(false);
34
+ });
35
+
36
+ it('returns false for non-Error values', () => {
37
+ expect(isAbortError('AbortError')).toBe(false);
38
+ expect(isAbortError(null)).toBe(false);
39
+ expect(isAbortError(undefined)).toBe(false);
40
+ });
41
+ });
42
+
43
+ describe('classifyError', () => {
44
+ it('classifies AbortError → abort', () => {
45
+ const err = new DOMException('aborted', 'AbortError');
46
+ const result = classifyError(err);
47
+ expect(result.code).toBe('abort');
48
+ expect(result.original).toBe(err);
49
+ });
50
+
51
+ it('classifies HttpError 401 → unauthorized', () => {
52
+ const err = new HttpError(401, 'Unauthorized');
53
+ const result = classifyError(err);
54
+ expect(result).toEqual<AppError>({
55
+ code: 'unauthorized',
56
+ message: 'Unauthorized',
57
+ status: 401,
58
+ original: err,
59
+ });
60
+ });
61
+
62
+ it('classifies HttpError 403 → forbidden', () => {
63
+ const result = classifyError(new HttpError(403));
64
+ expect(result.code).toBe('forbidden');
65
+ expect(result.status).toBe(403);
66
+ });
67
+
68
+ it('classifies HttpError 404 → not_found', () => {
69
+ const result = classifyError(new HttpError(404));
70
+ expect(result.code).toBe('not_found');
71
+ expect(result.status).toBe(404);
72
+ });
73
+
74
+ it('classifies HttpError 422 → validation', () => {
75
+ const result = classifyError(new HttpError(422));
76
+ expect(result.code).toBe('validation');
77
+ expect(result.status).toBe(422);
78
+ });
79
+
80
+ it('classifies HttpError 429 → rate_limited', () => {
81
+ const result = classifyError(new HttpError(429));
82
+ expect(result.code).toBe('rate_limited');
83
+ expect(result.status).toBe(429);
84
+ });
85
+
86
+ it('classifies HttpError 500 → server_error', () => {
87
+ const result = classifyError(new HttpError(500));
88
+ expect(result.code).toBe('server_error');
89
+ expect(result.status).toBe(500);
90
+ });
91
+
92
+ it('classifies HttpError 503 → server_error', () => {
93
+ const result = classifyError(new HttpError(503));
94
+ expect(result.code).toBe('server_error');
95
+ expect(result.status).toBe(503);
96
+ });
97
+
98
+ it('classifies HttpError with unknown status → unknown', () => {
99
+ const result = classifyError(new HttpError(418));
100
+ expect(result.code).toBe('unknown');
101
+ expect(result.status).toBe(418);
102
+ });
103
+
104
+ it('classifies Response object by status', () => {
105
+ const res = new Response(null, { status: 404, statusText: 'Not Found' });
106
+ const result = classifyError(res);
107
+ expect(result.code).toBe('not_found');
108
+ expect(result.status).toBe(404);
109
+ expect(result.message).toBe('Not Found');
110
+ expect(result.original).toBe(res);
111
+ });
112
+
113
+ it('classifies Response with empty statusText', () => {
114
+ const res = new Response(null, { status: 500 });
115
+ const result = classifyError(res);
116
+ expect(result.code).toBe('server_error');
117
+ expect(result.message).toBe('HTTP 500');
118
+ });
119
+
120
+ it('classifies TypeError with "fetch" → network', () => {
121
+ const err = new TypeError('Failed to fetch');
122
+ const result = classifyError(err);
123
+ expect(result.code).toBe('network');
124
+ expect(result.original).toBe(err);
125
+ });
126
+
127
+ it('classifies TimeoutError → timeout', () => {
128
+ const err = new Error('Operation timed out');
129
+ err.name = 'TimeoutError';
130
+ const result = classifyError(err);
131
+ expect(result.code).toBe('timeout');
132
+ expect(result.original).toBe(err);
133
+ });
134
+
135
+ it('classifies generic Error → unknown', () => {
136
+ const err = new Error('Something broke');
137
+ const result = classifyError(err);
138
+ expect(result.code).toBe('unknown');
139
+ expect(result.message).toBe('Something broke');
140
+ expect(result.original).toBe(err);
141
+ });
142
+
143
+ it('classifies non-Error value → unknown', () => {
144
+ const result = classifyError('string error');
145
+ expect(result.code).toBe('unknown');
146
+ expect(result.message).toBe('string error');
147
+ expect(result.original).toBe('string error');
148
+ });
149
+
150
+ it('classifies null → unknown', () => {
151
+ const result = classifyError(null);
152
+ expect(result.code).toBe('unknown');
153
+ expect(result.message).toBe('null');
154
+ });
155
+ });
package/src/errors.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Canonical application error shape for consistent error handling.
3
+ */
4
+ export interface AppError {
5
+ code:
6
+ | 'unauthorized'
7
+ | 'forbidden'
8
+ | 'not_found'
9
+ | 'validation'
10
+ | 'rate_limited'
11
+ | 'server_error'
12
+ | 'network'
13
+ | 'timeout'
14
+ | 'abort'
15
+ | 'unknown';
16
+ message: string;
17
+ status?: number;
18
+ original?: unknown;
19
+ }
20
+
21
+ /**
22
+ * Typed HTTP error for services to throw.
23
+ */
24
+ export class HttpError extends Error {
25
+ constructor(
26
+ public readonly status: number,
27
+ message?: string
28
+ ) {
29
+ super(message ?? `HTTP ${status}`);
30
+ this.name = 'HttpError';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Guard for AbortError — the most-repeated check in async ViewModels.
36
+ * Uses duck-typing so the core lib doesn't require DOM types.
37
+ */
38
+ export function isAbortError(error: unknown): boolean {
39
+ return error instanceof Error && error.name === 'AbortError';
40
+ }
41
+
42
+ function classifyHttpStatus(
43
+ status: number
44
+ ): AppError['code'] {
45
+ if (status === 401) return 'unauthorized';
46
+ if (status === 403) return 'forbidden';
47
+ if (status === 404) return 'not_found';
48
+ if (status === 422) return 'validation';
49
+ if (status === 429) return 'rate_limited';
50
+ if (status >= 500) return 'server_error';
51
+ return 'unknown';
52
+ }
53
+
54
+ function isResponseLike(value: unknown): value is { status: number; statusText: string } {
55
+ return (
56
+ typeof value === 'object' &&
57
+ value !== null &&
58
+ typeof (value as Record<string, unknown>).status === 'number' &&
59
+ typeof (value as Record<string, unknown>).statusText === 'string' &&
60
+ !(value instanceof Error)
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Maps raw errors to a canonical AppError shape.
66
+ */
67
+ export function classifyError(error: unknown): AppError {
68
+ // AbortError (fetch cancelled / DOMException)
69
+ if (error instanceof Error && error.name === 'AbortError') {
70
+ return {
71
+ code: 'abort',
72
+ message: 'Request was aborted',
73
+ original: error,
74
+ };
75
+ }
76
+
77
+ // HttpError (thrown by services)
78
+ if (error instanceof HttpError) {
79
+ return {
80
+ code: classifyHttpStatus(error.status),
81
+ message: error.message,
82
+ status: error.status,
83
+ original: error,
84
+ };
85
+ }
86
+
87
+ // Raw Response object (duck-typed: has status + statusText, is not an Error)
88
+ if (isResponseLike(error)) {
89
+ return {
90
+ code: classifyHttpStatus(error.status),
91
+ message: error.statusText || `HTTP ${error.status}`,
92
+ status: error.status,
93
+ original: error,
94
+ };
95
+ }
96
+
97
+ // Network error (fetch failure)
98
+ if (
99
+ error instanceof TypeError &&
100
+ error.message.toLowerCase().includes('fetch')
101
+ ) {
102
+ return {
103
+ code: 'network',
104
+ message: error.message,
105
+ original: error,
106
+ };
107
+ }
108
+
109
+ // Timeout error
110
+ if (error instanceof Error && error.name === 'TimeoutError') {
111
+ return {
112
+ code: 'timeout',
113
+ message: error.message,
114
+ original: error,
115
+ };
116
+ }
117
+
118
+ // Generic Error fallback
119
+ if (error instanceof Error) {
120
+ return {
121
+ code: 'unknown',
122
+ message: error.message,
123
+ original: error,
124
+ };
125
+ }
126
+
127
+ // Non-Error fallback
128
+ return {
129
+ code: 'unknown',
130
+ message: String(error),
131
+ original: error,
132
+ };
133
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ // Types
2
+ export type {
3
+ Listener,
4
+ Updater,
5
+ Subscribable,
6
+ Disposable,
7
+ Initializable,
8
+ ValidationErrors,
9
+ TaskState,
10
+ EventSource,
11
+ EventPayload,
12
+ } from './types';
13
+
14
+ export type { AsyncMethodKeys } from './ViewModel';
15
+ export type { ResourceAsyncMethodKeys } from './Resource';
16
+
17
+ // Core primitives
18
+ export { ViewModel } from './ViewModel';
19
+ export { Model } from './Model';
20
+ export { Collection } from './Collection';
21
+ export { PersistentCollection } from './PersistentCollection';
22
+ export { Resource } from './Resource';
23
+ export { Controller } from './Controller';
24
+ export { Service } from './Service';
25
+ export { EventBus } from './EventBus';
26
+ export { Channel } from './Channel';
27
+ export type { ChannelStatus } from './Channel';
28
+ export { Trackable } from './Trackable';
29
+
30
+ // Composable helpers
31
+ export { Sorting } from './Sorting';
32
+ export type { SortDescriptor } from './Sorting';
33
+ export { Pagination } from './Pagination';
34
+ export { Selection } from './Selection';
35
+ export { Feed } from './Feed';
36
+ export type { FeedPage } from './Feed';
37
+ export { Pending } from './Pending';
38
+ export type { PendingOperation, PendingEntry } from './Pending';
39
+
40
+ // Error handling utilities
41
+ export type { AppError } from './errors';
42
+ export { HttpError, isAbortError, classifyError } from './errors';
43
+
44
+ // Utilities
45
+ export { bindPublicMethods } from './bindPublicMethods';
46
+ export { produceDraft, resolveDraftUpdater } from './produceDraft';
47
+
48
+ // Singleton registry
49
+ export { singleton, hasSingleton, teardown, teardownAll } from './singleton';
@@ -0,0 +1,90 @@
1
+ # produceDraft
2
+
3
+ Standalone copy-on-write draft utility. Creates a proxy of a frozen state object, runs a mutator function, and returns only the changed top-level keys as a `Partial`. Used internally by `ViewModel.set()` and `Model.set()` for draft mode, but also exported for standalone use.
4
+
5
+ ---
6
+
7
+ ## API
8
+
9
+ ```typescript
10
+ import { produceDraft } from 'mvc-kit';
11
+
12
+ function produceDraft<S extends object>(
13
+ state: Readonly<S>,
14
+ mutator: (draft: S) => void,
15
+ ): Partial<S> | null;
16
+ ```
17
+
18
+ Returns a `Partial<S>` containing only the top-level keys that changed, or `null` if nothing was modified.
19
+
20
+ ---
21
+
22
+ ## Examples
23
+
24
+ ### Flat mutation
25
+
26
+ ```typescript
27
+ const state = { name: 'Alice', age: 30 };
28
+ const changes = produceDraft(state, draft => {
29
+ draft.name = 'Bob';
30
+ });
31
+ // changes: { name: 'Bob' }
32
+ ```
33
+
34
+ ### Nested mutation
35
+
36
+ Nested plain objects are proxied recursively. Mutating a nested property triggers a shallow copy of each object on the path, preserving structural sharing for unchanged subtrees.
37
+
38
+ ```typescript
39
+ const state = {
40
+ user: { name: 'Alice', address: { city: 'NYC' } },
41
+ settings: { theme: 'dark' },
42
+ };
43
+
44
+ const changes = produceDraft(state, draft => {
45
+ draft.user.address.city = 'LA';
46
+ });
47
+ // changes: { user: { name: 'Alice', address: { city: 'LA' } } }
48
+ // state.settings === changes is not present — unchanged subtree untouched
49
+ ```
50
+
51
+ ### Structural sharing
52
+
53
+ Unchanged subtrees keep their original references:
54
+
55
+ ```typescript
56
+ const state = { a: { x: 1 }, b: { y: 2 } };
57
+ const changes = produceDraft(state, draft => {
58
+ draft.a.x = 99;
59
+ });
60
+ // changes.a is a new object ({ x: 99 })
61
+ // state.b was never copied — it's not in the partial at all
62
+ ```
63
+
64
+ ### No-op detection
65
+
66
+ Assigning the same value by reference produces no changes:
67
+
68
+ ```typescript
69
+ const state = { name: 'Alice', age: 30 };
70
+ const changes = produceDraft(state, draft => {
71
+ draft.name = 'Alice'; // same value
72
+ });
73
+ // changes: null
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Limitations
79
+
80
+ - **Arrays** are not proxied. Mutating an array in place (`push`, `splice`, index assignment) is not detected. Replace the entire array via assignment instead:
81
+
82
+ ```typescript
83
+ // Bad: push is not detected
84
+ draft.items.push(newItem);
85
+
86
+ // Good: replace the array
87
+ draft.items = [...draft.items, newItem];
88
+ ```
89
+
90
+ - **Class instances, Dates, Maps, Sets** are not proxied. Only plain objects (`Object.prototype` or `null` prototype) receive copy-on-write proxies. Non-POJO values pass through as-is and must be replaced via assignment.