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.
- 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 +19 -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,116 @@
|
|
|
1
|
+
import { PersistentCollection } from '../PersistentCollection';
|
|
2
|
+
|
|
3
|
+
const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PersistentCollection backed by localStorage or sessionStorage.
|
|
7
|
+
* Auto-hydrates on first access (sync storage — no need for manual `hydrate()` call).
|
|
8
|
+
*
|
|
9
|
+
* Uses blob strategy: all items stored as a single JSON string under `storageKey`.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* class CartCollection extends WebStorageCollection<CartItem> {
|
|
13
|
+
* protected readonly storageKey = 'cart';
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export abstract class WebStorageCollection<
|
|
18
|
+
T extends { id: string | number },
|
|
19
|
+
> extends PersistentCollection<T> {
|
|
20
|
+
/** Which Web Storage backend to use. Override to 'session' for sessionStorage. */
|
|
21
|
+
static STORAGE: 'local' | 'session' = 'local';
|
|
22
|
+
|
|
23
|
+
private _autoHydrated = false;
|
|
24
|
+
|
|
25
|
+
private get _storage(): Storage {
|
|
26
|
+
return (this.constructor as typeof WebStorageCollection).STORAGE === 'session'
|
|
27
|
+
? sessionStorage
|
|
28
|
+
: localStorage;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private _ensureHydrated(): void {
|
|
32
|
+
if (this._autoHydrated) return;
|
|
33
|
+
this._autoHydrated = true;
|
|
34
|
+
this._hydrateSync();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Override access points to trigger lazy hydration ──
|
|
38
|
+
|
|
39
|
+
get items(): T[] {
|
|
40
|
+
this._ensureHydrated();
|
|
41
|
+
return super.items;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get state(): T[] {
|
|
45
|
+
return this.items;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get length(): number {
|
|
49
|
+
this._ensureHydrated();
|
|
50
|
+
return super.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get(id: T['id']): T | undefined {
|
|
54
|
+
this._ensureHydrated();
|
|
55
|
+
return super.get(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
has(id: T['id']): boolean {
|
|
59
|
+
this._ensureHydrated();
|
|
60
|
+
return super.has(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Persist interface (blob strategy) ──
|
|
64
|
+
|
|
65
|
+
protected persistGetAll(): T[] {
|
|
66
|
+
const raw = this._storage.getItem(this.storageKey);
|
|
67
|
+
if (!raw) return [];
|
|
68
|
+
try {
|
|
69
|
+
return this.deserialize(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
if (__DEV__) {
|
|
72
|
+
console.warn(
|
|
73
|
+
`[mvc-kit] Corrupted data in storage key "${this.storageKey}". Ignoring stored data.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
protected persistGet(id: T['id']): T | null {
|
|
81
|
+
const all = this.persistGetAll();
|
|
82
|
+
return all.find((i) => i.id === id) ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
protected persistSet(_items: T[]): void {
|
|
86
|
+
try {
|
|
87
|
+
this._storage.setItem(this.storageKey, this.serialize([...this.items]));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (__DEV__ && err instanceof DOMException && err.name === 'QuotaExceededError') {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[mvc-kit] QuotaExceededError writing to "${this.storageKey}". ` +
|
|
92
|
+
`Consider using IndexedDBCollection for larger datasets.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
protected persistRemove(_ids: T['id'][]): void {
|
|
100
|
+
try {
|
|
101
|
+
this._storage.setItem(this.storageKey, this.serialize([...this.items]));
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (__DEV__ && err instanceof DOMException && err.name === 'QuotaExceededError') {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[mvc-kit] QuotaExceededError writing to "${this.storageKey}". ` +
|
|
106
|
+
`Consider using IndexedDBCollection for larger datasets.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
protected persistClear(): void {
|
|
114
|
+
this._storage.removeItem(this.storageKey);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/web/idb.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared IndexedDB connection manager.
|
|
3
|
+
* Deduplicates indexedDB.open() calls and handles dynamic object store creation
|
|
4
|
+
* by bumping the DB version when a new storageKey is encountered.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const _connections = new Map<string, IDBDatabase>();
|
|
8
|
+
const _stores = new Map<string, Set<string>>(); // dbName → known store names
|
|
9
|
+
let _openQueue: Promise<void> = Promise.resolve(); // Sequential open queue
|
|
10
|
+
|
|
11
|
+
function openDB(dbName: string, storeName: string): Promise<IDBDatabase> {
|
|
12
|
+
// If we already have a connection with the required store, reuse it
|
|
13
|
+
const existing = _connections.get(dbName);
|
|
14
|
+
if (existing) {
|
|
15
|
+
if (existing.objectStoreNames.contains(storeName)) {
|
|
16
|
+
return Promise.resolve(existing);
|
|
17
|
+
}
|
|
18
|
+
// Need to add a new store — close and reopen with bumped version
|
|
19
|
+
existing.close();
|
|
20
|
+
_connections.delete(dbName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Track known stores
|
|
24
|
+
let stores = _stores.get(dbName);
|
|
25
|
+
if (!stores) {
|
|
26
|
+
stores = new Set();
|
|
27
|
+
_stores.set(dbName, stores);
|
|
28
|
+
}
|
|
29
|
+
stores.add(storeName);
|
|
30
|
+
|
|
31
|
+
// Serialize opens to prevent version conflicts
|
|
32
|
+
const result = _openQueue.then(() => doOpen(dbName, stores!));
|
|
33
|
+
_openQueue = result.then(() => {}, () => {}); // Absorb errors for the queue
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function doOpen(dbName: string, stores: Set<string>): Promise<IDBDatabase> {
|
|
38
|
+
// Close existing cached connection if any (may have been opened by queued op)
|
|
39
|
+
const existingDb = _connections.get(dbName);
|
|
40
|
+
existingDb?.close();
|
|
41
|
+
_connections.delete(dbName);
|
|
42
|
+
|
|
43
|
+
// Probe current DB version (version-less open never triggers upgrade)
|
|
44
|
+
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
45
|
+
const probe = indexedDB.open(dbName);
|
|
46
|
+
probe.onsuccess = () => {
|
|
47
|
+
const db = probe.result;
|
|
48
|
+
const version = db.version;
|
|
49
|
+
|
|
50
|
+
// Check if all required stores already exist
|
|
51
|
+
let needsUpgrade = false;
|
|
52
|
+
for (const name of stores) {
|
|
53
|
+
if (!db.objectStoreNames.contains(name)) {
|
|
54
|
+
needsUpgrade = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!needsUpgrade) {
|
|
60
|
+
_connections.set(dbName, db);
|
|
61
|
+
resolve(db);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Need new stores — close and reopen with bumped version
|
|
66
|
+
db.close();
|
|
67
|
+
const upgrade = indexedDB.open(dbName, version + 1);
|
|
68
|
+
upgrade.onupgradeneeded = () => {
|
|
69
|
+
const udb = upgrade.result;
|
|
70
|
+
for (const name of stores) {
|
|
71
|
+
if (!udb.objectStoreNames.contains(name)) {
|
|
72
|
+
udb.createObjectStore(name, { keyPath: 'id' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
upgrade.onsuccess = () => {
|
|
77
|
+
_connections.set(dbName, upgrade.result);
|
|
78
|
+
resolve(upgrade.result);
|
|
79
|
+
};
|
|
80
|
+
upgrade.onerror = () => reject(upgrade.error);
|
|
81
|
+
};
|
|
82
|
+
probe.onerror = () => reject(probe.error);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Open a transaction and return the object store for the given database and store name.
|
|
88
|
+
*/
|
|
89
|
+
export function getStore(
|
|
90
|
+
dbName: string,
|
|
91
|
+
storeName: string,
|
|
92
|
+
mode: IDBTransactionMode,
|
|
93
|
+
): Promise<IDBObjectStore> {
|
|
94
|
+
return openDB(dbName, storeName).then((db) => {
|
|
95
|
+
const tx = db.transaction(storeName, mode);
|
|
96
|
+
return tx.objectStore(storeName);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Retrieve all items from an IndexedDB object store.
|
|
102
|
+
*/
|
|
103
|
+
export function idbGetAll<T>(store: IDBObjectStore): Promise<T[]> {
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const request = store.getAll();
|
|
106
|
+
request.onsuccess = () => resolve(request.result);
|
|
107
|
+
request.onerror = () => reject(request.error);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Retrieve a single item by key from an IndexedDB object store.
|
|
113
|
+
*/
|
|
114
|
+
export function idbGet<T>(store: IDBObjectStore, id: IDBValidKey): Promise<T | null> {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const request = store.get(id);
|
|
117
|
+
request.onsuccess = () => resolve(request.result ?? null);
|
|
118
|
+
request.onerror = () => reject(request.error);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Write (put) multiple items into an IndexedDB object store.
|
|
124
|
+
*/
|
|
125
|
+
export function idbPut<T>(store: IDBObjectStore, items: T[]): Promise<void> {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const tx = store.transaction;
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
store.put(item);
|
|
130
|
+
}
|
|
131
|
+
tx.oncomplete = () => resolve();
|
|
132
|
+
tx.onerror = () => reject(tx.error);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Delete multiple items by key from an IndexedDB object store.
|
|
138
|
+
*/
|
|
139
|
+
export function idbDelete(store: IDBObjectStore, ids: IDBValidKey[]): Promise<void> {
|
|
140
|
+
return new Promise((resolve, reject) => {
|
|
141
|
+
const tx = store.transaction;
|
|
142
|
+
for (const id of ids) {
|
|
143
|
+
store.delete(id);
|
|
144
|
+
}
|
|
145
|
+
tx.oncomplete = () => resolve();
|
|
146
|
+
tx.onerror = () => reject(tx.error);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Clear all items from an IndexedDB object store.
|
|
152
|
+
*/
|
|
153
|
+
export function idbClear(store: IDBObjectStore): Promise<void> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const tx = store.transaction;
|
|
156
|
+
store.clear();
|
|
157
|
+
tx.oncomplete = () => resolve();
|
|
158
|
+
tx.onerror = () => reject(tx.error);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Close all cached connections and delete all known databases. Used in test cleanup. */
|
|
163
|
+
export function closeAllConnections(): void {
|
|
164
|
+
for (const db of _connections.values()) {
|
|
165
|
+
db.close();
|
|
166
|
+
}
|
|
167
|
+
_connections.clear();
|
|
168
|
+
_stores.clear();
|
|
169
|
+
_openQueue = Promise.resolve();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Delete a database by name. Returns a promise that resolves when deleted. */
|
|
173
|
+
export function deleteDatabase(dbName: string): Promise<void> {
|
|
174
|
+
const existing = _connections.get(dbName);
|
|
175
|
+
existing?.close();
|
|
176
|
+
_connections.delete(dbName);
|
|
177
|
+
_stores.delete(dbName);
|
|
178
|
+
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const request = indexedDB.deleteDatabase(dbName);
|
|
181
|
+
request.onsuccess = () => resolve();
|
|
182
|
+
request.onerror = () => reject(request.error);
|
|
183
|
+
});
|
|
184
|
+
}
|
package/src/web/index.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { isAbortError, classifyError } from './errors';
|
|
2
|
+
import { walkPrototypeChain } from './walkPrototypeChain';
|
|
3
|
+
import type { TaskState } from './types';
|
|
4
|
+
|
|
5
|
+
const __DEV__ = typeof __MVC_KIT_DEV__ !== 'undefined' && __MVC_KIT_DEV__;
|
|
6
|
+
|
|
7
|
+
const LOADING_TASK_STATE: TaskState = Object.freeze({ loading: true, error: null, errorCode: null });
|
|
8
|
+
|
|
9
|
+
/** @internal Mutable internal async tracking state per method. */
|
|
10
|
+
export interface InternalTaskState {
|
|
11
|
+
loading: boolean;
|
|
12
|
+
error: string | null;
|
|
13
|
+
errorCode: TaskState['errorCode'];
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @internal Configuration for the shared async method wrapping logic. */
|
|
18
|
+
export interface AsyncTrackingContext {
|
|
19
|
+
instance: object;
|
|
20
|
+
stopPrototype: object;
|
|
21
|
+
reservedKeys: readonly string[];
|
|
22
|
+
lifecycleHooks: Set<string>;
|
|
23
|
+
isDisposed: () => boolean;
|
|
24
|
+
isInitialized: () => boolean;
|
|
25
|
+
asyncStates: Map<string, InternalTaskState>;
|
|
26
|
+
asyncSnapshots: Map<string, TaskState>;
|
|
27
|
+
asyncListeners: Set<() => void>;
|
|
28
|
+
notifyAsync: () => void;
|
|
29
|
+
addCleanup: (fn: () => void) => void;
|
|
30
|
+
ghostTimeout: number;
|
|
31
|
+
className: string;
|
|
32
|
+
activeOps: Map<string, number> | null;
|
|
33
|
+
/** Pre-scanned methods from class metadata cache. When provided, skips prototype walk. */
|
|
34
|
+
methods?: Array<{ key: string; fn: Function }>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Shared async method wrapping logic used by both ViewModel and Resource.
|
|
39
|
+
* Walks the prototype chain, wraps methods with async tracking, and registers cleanup.
|
|
40
|
+
* Returns the list of wrapped method keys.
|
|
41
|
+
*/
|
|
42
|
+
export function wrapAsyncMethods(ctx: AsyncTrackingContext): string[] {
|
|
43
|
+
const {
|
|
44
|
+
instance,
|
|
45
|
+
stopPrototype,
|
|
46
|
+
reservedKeys,
|
|
47
|
+
lifecycleHooks,
|
|
48
|
+
isDisposed,
|
|
49
|
+
isInitialized,
|
|
50
|
+
asyncStates,
|
|
51
|
+
asyncSnapshots,
|
|
52
|
+
asyncListeners,
|
|
53
|
+
notifyAsync,
|
|
54
|
+
addCleanup,
|
|
55
|
+
ghostTimeout,
|
|
56
|
+
className,
|
|
57
|
+
activeOps,
|
|
58
|
+
} = ctx;
|
|
59
|
+
|
|
60
|
+
// Instance property reserved key check (DEV-only — prototype check in constructor catches most cases)
|
|
61
|
+
// Skip own properties that are bound methods from bindPublicMethods (the key
|
|
62
|
+
// also exists as a method on the prototype chain). Only reject class-field overrides.
|
|
63
|
+
if (__DEV__) {
|
|
64
|
+
for (const key of reservedKeys) {
|
|
65
|
+
if (Object.getOwnPropertyDescriptor(instance, key)?.value !== undefined) {
|
|
66
|
+
// Check if the prototype chain has a method with this name —
|
|
67
|
+
// if so, the own property is from bindPublicMethods, not a user override
|
|
68
|
+
let fromPrototype = false;
|
|
69
|
+
let proto = Object.getPrototypeOf(instance);
|
|
70
|
+
while (proto && proto !== Object.prototype) {
|
|
71
|
+
const desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
72
|
+
if (desc && typeof desc.value === 'function') { fromPrototype = true; break; }
|
|
73
|
+
proto = Object.getPrototypeOf(proto);
|
|
74
|
+
}
|
|
75
|
+
if (!fromPrototype) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[mvc-kit] "${key}" is a reserved property on ${className} and cannot be overridden.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Use pre-scanned methods from class metadata cache, or walk prototype chain
|
|
85
|
+
const methodEntries: Array<{ key: string; fn: Function }> = ctx.methods ?? (() => {
|
|
86
|
+
const result: Array<{ key: string; fn: Function }> = [];
|
|
87
|
+
const processed = new Set<string>();
|
|
88
|
+
walkPrototypeChain(instance, stopPrototype, (key, desc) => {
|
|
89
|
+
if (desc.get || desc.set) return;
|
|
90
|
+
if (typeof desc.value !== 'function') return;
|
|
91
|
+
if (key.startsWith('_')) return;
|
|
92
|
+
if (lifecycleHooks.has(key)) return;
|
|
93
|
+
if (processed.has(key)) return;
|
|
94
|
+
processed.add(key);
|
|
95
|
+
result.push({ key, fn: desc.value });
|
|
96
|
+
});
|
|
97
|
+
return result;
|
|
98
|
+
})();
|
|
99
|
+
|
|
100
|
+
const wrappedKeys: string[] = [];
|
|
101
|
+
|
|
102
|
+
for (const { key, fn: original } of methodEntries) {
|
|
103
|
+
let pruned = false;
|
|
104
|
+
|
|
105
|
+
const wrapper = function (this: any, ...args: unknown[]) {
|
|
106
|
+
// Disposed guard
|
|
107
|
+
if (isDisposed()) {
|
|
108
|
+
if (__DEV__) {
|
|
109
|
+
console.warn(`[mvc-kit] "${key}" called after dispose — ignored.`);
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pre-init guard (DEV only — method still executes)
|
|
115
|
+
if (__DEV__ && !isInitialized()) {
|
|
116
|
+
console.warn(
|
|
117
|
+
`[mvc-kit] "${key}" called before init(). ` +
|
|
118
|
+
`Async tracking is active only after init().`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let result: unknown;
|
|
123
|
+
try {
|
|
124
|
+
result = original.apply(instance, args);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
// Sync throw — not tracked as async
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sync detection: if not thenable, prune from async tracking
|
|
131
|
+
if (!result || typeof (result as any).then !== 'function') {
|
|
132
|
+
if (!pruned) {
|
|
133
|
+
pruned = true;
|
|
134
|
+
// Remove from async maps
|
|
135
|
+
asyncStates.delete(key);
|
|
136
|
+
asyncSnapshots.delete(key);
|
|
137
|
+
// Replace wrapper with bound original for zero overhead
|
|
138
|
+
(instance as any)[key] = original.bind(instance);
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Async tracking ──────────────────────────────────────
|
|
144
|
+
let internal = asyncStates.get(key);
|
|
145
|
+
if (!internal) {
|
|
146
|
+
internal = { loading: false, error: null, errorCode: null, count: 0 };
|
|
147
|
+
asyncStates.set(key, internal);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
internal.count++;
|
|
151
|
+
internal.loading = true;
|
|
152
|
+
internal.error = null;
|
|
153
|
+
internal.errorCode = null;
|
|
154
|
+
asyncSnapshots.set(key, LOADING_TASK_STATE);
|
|
155
|
+
notifyAsync();
|
|
156
|
+
|
|
157
|
+
if (__DEV__ && activeOps) {
|
|
158
|
+
activeOps.set(key, (activeOps.get(key) ?? 0) + 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Shared bookkeeping: decrement count, snapshot state, update DEV active ops
|
|
162
|
+
const finalizeOp = (errorMsg?: string | null, errorCode?: TaskState['errorCode']) => {
|
|
163
|
+
internal!.count--;
|
|
164
|
+
internal!.loading = internal!.count > 0;
|
|
165
|
+
if (errorMsg !== undefined) {
|
|
166
|
+
internal!.error = errorMsg;
|
|
167
|
+
internal!.errorCode = errorCode ?? null;
|
|
168
|
+
}
|
|
169
|
+
asyncSnapshots.set(
|
|
170
|
+
key,
|
|
171
|
+
Object.freeze({ loading: internal!.loading, error: internal!.error, errorCode: internal!.errorCode }),
|
|
172
|
+
);
|
|
173
|
+
notifyAsync();
|
|
174
|
+
|
|
175
|
+
if (__DEV__ && activeOps) {
|
|
176
|
+
const c = (activeOps.get(key) ?? 1) - 1;
|
|
177
|
+
if (c <= 0) activeOps.delete(key);
|
|
178
|
+
else activeOps.set(key, c);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return (result as Promise<unknown>).then(
|
|
183
|
+
(value) => {
|
|
184
|
+
if (!isDisposed()) finalizeOp();
|
|
185
|
+
return value;
|
|
186
|
+
},
|
|
187
|
+
(error) => {
|
|
188
|
+
// AbortError — silently swallow
|
|
189
|
+
if (isAbortError(error)) {
|
|
190
|
+
if (!isDisposed()) finalizeOp();
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Disposed — fizzle silently
|
|
195
|
+
if (isDisposed()) return undefined;
|
|
196
|
+
|
|
197
|
+
const classified = classifyError(error);
|
|
198
|
+
finalizeOp(classified.message, classified.code);
|
|
199
|
+
|
|
200
|
+
// Re-throw to preserve standard Promise rejection
|
|
201
|
+
throw error;
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
wrappedKeys.push(key);
|
|
207
|
+
(instance as any)[key] = wrapper;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Register cleanup for disposal
|
|
211
|
+
if (wrappedKeys.length > 0) {
|
|
212
|
+
addCleanup(() => {
|
|
213
|
+
// Snapshot active ops for ghost check before clearing
|
|
214
|
+
const opsSnapshot = __DEV__ && activeOps ? new Map(activeOps) : null;
|
|
215
|
+
|
|
216
|
+
// Swap all wrapped methods to no-ops (with DEV warning)
|
|
217
|
+
for (const k of wrappedKeys) {
|
|
218
|
+
if (__DEV__) {
|
|
219
|
+
(instance as any)[k] = () => {
|
|
220
|
+
console.warn(`[mvc-kit] "${k}" called after dispose — ignored.`);
|
|
221
|
+
return undefined;
|
|
222
|
+
};
|
|
223
|
+
} else {
|
|
224
|
+
(instance as any)[k] = () => undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Clear async state
|
|
229
|
+
asyncListeners.clear();
|
|
230
|
+
asyncStates.clear();
|
|
231
|
+
asyncSnapshots.clear();
|
|
232
|
+
|
|
233
|
+
// DEV: schedule ghost check
|
|
234
|
+
if (__DEV__ && opsSnapshot && opsSnapshot.size > 0) {
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
for (const [key, count] of opsSnapshot) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`[mvc-kit] Ghost async operation detected: "${key}" had ${count} ` +
|
|
239
|
+
`pending call(s) when the ${className} was disposed. ` +
|
|
240
|
+
`Consider using disposeSignal to cancel in-flight work.`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}, ghostTimeout);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return wrappedKeys;
|
|
249
|
+
}
|