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.
- package/agent-config/bin/postinstall.mjs +5 -3
- package/agent-config/bin/setup.mjs +3 -4
- package/agent-config/claude-code/agents/mvc-kit-architect.md +14 -0
- package/agent-config/claude-code/skills/guide/api-reference.md +24 -2
- package/agent-config/lib/install-claude.mjs +10 -33
- package/dist/Model.cjs +9 -1
- package/dist/Model.cjs.map +1 -1
- package/dist/Model.d.ts +1 -1
- package/dist/Model.d.ts.map +1 -1
- package/dist/Model.js +9 -1
- package/dist/Model.js.map +1 -1
- package/dist/ViewModel.cjs +9 -1
- package/dist/ViewModel.cjs.map +1 -1
- package/dist/ViewModel.d.ts +1 -1
- package/dist/ViewModel.d.ts.map +1 -1
- package/dist/ViewModel.js +9 -1
- package/dist/ViewModel.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mvc-kit.cjs +3 -0
- package/dist/mvc-kit.cjs.map +1 -1
- package/dist/mvc-kit.js +3 -0
- package/dist/mvc-kit.js.map +1 -1
- package/dist/produceDraft.cjs +105 -0
- package/dist/produceDraft.cjs.map +1 -0
- package/dist/produceDraft.d.ts +19 -0
- package/dist/produceDraft.d.ts.map +1 -0
- package/dist/produceDraft.js +105 -0
- package/dist/produceDraft.js.map +1 -0
- package/package.json +4 -2
- package/src/Channel.md +408 -0
- package/src/Channel.test.ts +957 -0
- package/src/Channel.ts +429 -0
- package/src/Collection.md +533 -0
- package/src/Collection.test.ts +1559 -0
- package/src/Collection.ts +653 -0
- package/src/Controller.md +306 -0
- package/src/Controller.test.ts +380 -0
- package/src/Controller.ts +90 -0
- package/src/EventBus.md +308 -0
- package/src/EventBus.test.ts +295 -0
- package/src/EventBus.ts +110 -0
- package/src/Feed.md +218 -0
- package/src/Feed.test.ts +442 -0
- package/src/Feed.ts +101 -0
- package/src/Model.md +524 -0
- package/src/Model.test.ts +642 -0
- package/src/Model.ts +260 -0
- package/src/Pagination.md +168 -0
- package/src/Pagination.test.ts +244 -0
- package/src/Pagination.ts +92 -0
- package/src/Pending.md +380 -0
- package/src/Pending.test.ts +1719 -0
- package/src/Pending.ts +390 -0
- package/src/PersistentCollection.md +183 -0
- package/src/PersistentCollection.test.ts +649 -0
- package/src/PersistentCollection.ts +375 -0
- package/src/Resource.ViewModel.test.ts +503 -0
- package/src/Resource.md +239 -0
- package/src/Resource.test.ts +786 -0
- package/src/Resource.ts +231 -0
- package/src/Selection.md +155 -0
- package/src/Selection.test.ts +326 -0
- package/src/Selection.ts +117 -0
- package/src/Service.md +440 -0
- package/src/Service.test.ts +241 -0
- package/src/Service.ts +72 -0
- package/src/Sorting.md +170 -0
- package/src/Sorting.test.ts +334 -0
- package/src/Sorting.ts +135 -0
- package/src/Trackable.md +166 -0
- package/src/Trackable.test.ts +236 -0
- package/src/Trackable.ts +129 -0
- package/src/ViewModel.async.test.ts +813 -0
- package/src/ViewModel.derived.test.ts +1583 -0
- package/src/ViewModel.md +1111 -0
- package/src/ViewModel.test.ts +1236 -0
- package/src/ViewModel.ts +800 -0
- package/src/bindPublicMethods.test.ts +126 -0
- package/src/bindPublicMethods.ts +48 -0
- package/src/env.d.ts +5 -0
- package/src/errors.test.ts +155 -0
- package/src/errors.ts +133 -0
- package/src/index.ts +49 -0
- package/src/produceDraft.md +90 -0
- package/src/produceDraft.test.ts +394 -0
- package/src/produceDraft.ts +168 -0
- package/src/react/components/CardList.md +97 -0
- package/src/react/components/CardList.test.tsx +142 -0
- package/src/react/components/CardList.tsx +68 -0
- package/src/react/components/DataTable.md +179 -0
- package/src/react/components/DataTable.test.tsx +599 -0
- package/src/react/components/DataTable.tsx +267 -0
- package/src/react/components/InfiniteScroll.md +116 -0
- package/src/react/components/InfiniteScroll.test.tsx +218 -0
- package/src/react/components/InfiniteScroll.tsx +70 -0
- package/src/react/components/types.ts +90 -0
- package/src/react/derived.test.tsx +261 -0
- package/src/react/guards.ts +24 -0
- package/src/react/index.ts +40 -0
- package/src/react/provider.test.tsx +143 -0
- package/src/react/provider.tsx +55 -0
- package/src/react/strict-mode.test.tsx +266 -0
- package/src/react/types.ts +25 -0
- package/src/react/use-event-bus.md +214 -0
- package/src/react/use-event-bus.test.tsx +168 -0
- package/src/react/use-event-bus.ts +40 -0
- package/src/react/use-instance.md +204 -0
- package/src/react/use-instance.test.tsx +350 -0
- package/src/react/use-instance.ts +60 -0
- package/src/react/use-local.md +457 -0
- package/src/react/use-local.rapid-remount.test.tsx +503 -0
- package/src/react/use-local.test.tsx +692 -0
- package/src/react/use-local.ts +165 -0
- package/src/react/use-model.md +364 -0
- package/src/react/use-model.test.tsx +394 -0
- package/src/react/use-model.ts +161 -0
- package/src/react/use-singleton.md +415 -0
- package/src/react/use-singleton.test.tsx +296 -0
- package/src/react/use-singleton.ts +69 -0
- package/src/react/use-subscribe-only.ts +39 -0
- package/src/react/use-teardown.md +169 -0
- package/src/react/use-teardown.test.tsx +86 -0
- package/src/react/use-teardown.ts +27 -0
- package/src/react-native/NativeCollection.test.ts +250 -0
- package/src/react-native/NativeCollection.ts +138 -0
- package/src/react-native/index.ts +1 -0
- package/src/singleton.md +310 -0
- package/src/singleton.test.ts +204 -0
- package/src/singleton.ts +70 -0
- package/src/types.ts +70 -0
- package/src/walkPrototypeChain.ts +22 -0
- package/src/web/IndexedDBCollection.test.ts +235 -0
- package/src/web/IndexedDBCollection.ts +66 -0
- package/src/web/WebStorageCollection.test.ts +214 -0
- package/src/web/WebStorageCollection.ts +116 -0
- package/src/web/idb.ts +184 -0
- package/src/web/index.ts +2 -0
- 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,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.
|