@arcmantle/chronicle 0.0.4
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/README.md +563 -0
- package/dist/api-methods.d.ts +28 -0
- package/dist/api-methods.d.ts.map +1 -0
- package/dist/api-methods.js +206 -0
- package/dist/api-methods.js.map +1 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +30 -0
- package/dist/api.js.map +1 -0
- package/dist/array-mutations.d.ts +31 -0
- package/dist/array-mutations.d.ts.map +1 -0
- package/dist/array-mutations.js +50 -0
- package/dist/array-mutations.js.map +1 -0
- package/dist/batch-transaction.d.ts +25 -0
- package/dist/batch-transaction.d.ts.map +1 -0
- package/dist/batch-transaction.js +138 -0
- package/dist/batch-transaction.js.map +1 -0
- package/dist/chronicle.d.ts +41 -0
- package/dist/chronicle.d.ts.map +1 -0
- package/dist/chronicle.js +40 -0
- package/dist/chronicle.js.map +1 -0
- package/dist/collection-adapters.d.ts +29 -0
- package/dist/collection-adapters.d.ts.map +1 -0
- package/dist/collection-adapters.js +184 -0
- package/dist/collection-adapters.js.map +1 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -0
- package/dist/grouping.d.ts +17 -0
- package/dist/grouping.d.ts.map +1 -0
- package/dist/grouping.js +35 -0
- package/dist/grouping.js.map +1 -0
- package/dist/history-recorder.d.ts +39 -0
- package/dist/history-recorder.d.ts.map +1 -0
- package/dist/history-recorder.js +112 -0
- package/dist/history-recorder.js.map +1 -0
- package/dist/history.d.ts +29 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +47 -0
- package/dist/history.js.map +1 -0
- package/dist/listener-affinity.d.ts +16 -0
- package/dist/listener-affinity.d.ts.map +1 -0
- package/dist/listener-affinity.js +58 -0
- package/dist/listener-affinity.js.map +1 -0
- package/dist/listener-trie.d.ts +10 -0
- package/dist/listener-trie.d.ts.map +1 -0
- package/dist/listener-trie.js +83 -0
- package/dist/listener-trie.js.map +1 -0
- package/dist/nameof.d.ts +12 -0
- package/dist/nameof.d.ts.map +1 -0
- package/dist/nameof.js +30 -0
- package/dist/nameof.js.map +1 -0
- package/dist/path-key.d.ts +11 -0
- package/dist/path-key.d.ts.map +1 -0
- package/dist/path-key.js +11 -0
- package/dist/path-key.js.map +1 -0
- package/dist/path.d.ts +7 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +53 -0
- package/dist/path.js.map +1 -0
- package/dist/proxy-cache.d.ts +32 -0
- package/dist/proxy-cache.d.ts.map +1 -0
- package/dist/proxy-cache.js +72 -0
- package/dist/proxy-cache.js.map +1 -0
- package/dist/proxy-factory.d.ts +17 -0
- package/dist/proxy-factory.d.ts.map +1 -0
- package/dist/proxy-factory.js +124 -0
- package/dist/proxy-factory.js.map +1 -0
- package/dist/schedule-queue.d.ts +10 -0
- package/dist/schedule-queue.d.ts.map +1 -0
- package/dist/schedule-queue.js +112 -0
- package/dist/schedule-queue.js.map +1 -0
- package/dist/snapshot-diff.d.ts +6 -0
- package/dist/snapshot-diff.d.ts.map +1 -0
- package/dist/snapshot-diff.js +67 -0
- package/dist/snapshot-diff.js.map +1 -0
- package/dist/symbol-id.d.ts +3 -0
- package/dist/symbol-id.d.ts.map +1 -0
- package/dist/symbol-id.js +16 -0
- package/dist/symbol-id.js.map +1 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/undo-redo.d.ts +12 -0
- package/dist/undo-redo.d.ts.map +1 -0
- package/dist/undo-redo.js +216 -0
- package/dist/undo-redo.js.map +1 -0
- package/package.json +45 -0
- package/src/api-methods.ts +292 -0
- package/src/api.ts +53 -0
- package/src/array-mutations.ts +64 -0
- package/src/batch-transaction.ts +183 -0
- package/src/chronicle.ts +100 -0
- package/src/collection-adapters.ts +224 -0
- package/src/config.ts +16 -0
- package/src/grouping.ts +47 -0
- package/src/history-recorder.ts +145 -0
- package/src/history.ts +75 -0
- package/src/listener-affinity.ts +69 -0
- package/src/listener-trie.ts +103 -0
- package/src/nameof.ts +42 -0
- package/src/path-key.ts +10 -0
- package/src/path.ts +69 -0
- package/src/proxy-cache.ts +86 -0
- package/src/proxy-factory.ts +168 -0
- package/src/schedule-queue.ts +144 -0
- package/src/snapshot-diff.ts +90 -0
- package/src/symbol-id.ts +20 -0
- package/src/tsconfig.json +3 -0
- package/src/types.ts +59 -0
- package/src/undo-redo.ts +249 -0
package/src/nameof.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { normalizePropertyKey } from './symbol-id.ts';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// nameof property path is stored here for retrieval.
|
|
5
|
+
const propertyThatWasAccessed: string[] = [];
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// Proxy objects to store the property path.
|
|
9
|
+
const proxy: any = new Proxy({} as any, {
|
|
10
|
+
get: <const C extends PropertyKey>(_: any, prop: C) => {
|
|
11
|
+
// Normalize to stable id so bracket keys with dots and Symbols become stable segments
|
|
12
|
+
propertyThatWasAccessed.push(normalizePropertyKey(prop));
|
|
13
|
+
|
|
14
|
+
return proxy;
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
export type Nameof<T> = (m: T extends object ? T : any) => any;
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns either the last part of a objects path \
|
|
24
|
+
* or dotted path if the fullPath flag is set to true.
|
|
25
|
+
*/
|
|
26
|
+
export const nameof = <const T>(expression: (instance: T) => any): string => {
|
|
27
|
+
propertyThatWasAccessed.length = 0;
|
|
28
|
+
expression(proxy);
|
|
29
|
+
|
|
30
|
+
return propertyThatWasAccessed.join('.');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns a fresh array of path segments captured from the selector expression.
|
|
35
|
+
* The returned array is a new copy to avoid external mutations affecting internals.
|
|
36
|
+
*/
|
|
37
|
+
export const nameofSegments = <const T>(expression: (instance: T) => any): string[] => {
|
|
38
|
+
propertyThatWasAccessed.length = 0;
|
|
39
|
+
expression(proxy);
|
|
40
|
+
|
|
41
|
+
return propertyThatWasAccessed.slice();
|
|
42
|
+
};
|
package/src/path-key.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a cache key from path segments.
|
|
3
|
+
*
|
|
4
|
+
* Uses ASCII Unit Separator (0x1F) as delimiter, which cannot appear in normal
|
|
5
|
+
* string keys, ensuring unambiguous path separation.
|
|
6
|
+
*
|
|
7
|
+
* @param segments - Path segments
|
|
8
|
+
* @returns Cache key string
|
|
9
|
+
*/
|
|
10
|
+
export const pathKeyOf = (segments: string[]): string => segments.join('\x1f');
|
package/src/path.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { normalizePropertyKey } from './symbol-id.ts';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
// Normalize property key to a stable string segment (symbols -> sym#id)
|
|
5
|
+
export const normalizeKey = (prop: PropertyKey): string => normalizePropertyKey(prop);
|
|
6
|
+
|
|
7
|
+
export const isArrayIndexKey = (k: string): boolean => /^(?:0|[1-9]\d*)$/.test(k);
|
|
8
|
+
|
|
9
|
+
export const getParentAndKey = (root: any, path: string[]): [ any, string ] | null => {
|
|
10
|
+
if (path.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
|
|
13
|
+
let parent: any = root;
|
|
14
|
+
for (const seg of path.slice(0, -1)) {
|
|
15
|
+
if (parent == null)
|
|
16
|
+
return null;
|
|
17
|
+
|
|
18
|
+
parent = (parent as any)[seg as any];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const last = path[path.length - 1]!;
|
|
22
|
+
|
|
23
|
+
return [ parent, last ];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const setAtPath = (root: any, path: string[], value: any): void => {
|
|
27
|
+
const res = getParentAndKey(root, path);
|
|
28
|
+
if (!res)
|
|
29
|
+
return;
|
|
30
|
+
|
|
31
|
+
const [ parent, key ] = res;
|
|
32
|
+
Reflect.set(parent, key, value);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const deleteAtPath = (root: any, path: string[]): void => {
|
|
36
|
+
const res = getParentAndKey(root, path);
|
|
37
|
+
if (!res)
|
|
38
|
+
return;
|
|
39
|
+
|
|
40
|
+
const [ parent, key ] = res;
|
|
41
|
+
if (parent == null)
|
|
42
|
+
return;
|
|
43
|
+
|
|
44
|
+
// If deleting from an array, prefer splice to avoid holes and adjust length
|
|
45
|
+
if (Array.isArray(parent) && isArrayIndexKey(String(key))) {
|
|
46
|
+
const idx = Number(key);
|
|
47
|
+
if (Number.isInteger(idx))
|
|
48
|
+
parent.splice(idx, 1);
|
|
49
|
+
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
Reflect.deleteProperty(parent, key as any);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const ensureParents = (root: any, path: string[]): void => {
|
|
57
|
+
let node: any = root;
|
|
58
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
59
|
+
const seg = path[i]!;
|
|
60
|
+
let next = (node as any)[seg as any];
|
|
61
|
+
if (next == null) {
|
|
62
|
+
const following = path[i + 1]!;
|
|
63
|
+
next = /^(?:0|[1-9]\d*)$/.test(following) ? [] : {};
|
|
64
|
+
(node as any)[seg as any] = next;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
node = next;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { getOptions } from './history.ts';
|
|
2
|
+
import { pathKeyOf } from './path-key.ts';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-root proxy cache: Map<pathKey, proxy>
|
|
7
|
+
* Enables stable proxy identity when cacheProxies option is enabled.
|
|
8
|
+
*/
|
|
9
|
+
const proxyCache: WeakMap<object, Map<string, any>> = new WeakMap();
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get a cached proxy for the given root and path.
|
|
14
|
+
*
|
|
15
|
+
* @param root - The root object
|
|
16
|
+
* @param pathKey - The path key (from pathKeyOf)
|
|
17
|
+
* @returns The cached proxy, or undefined if not cached
|
|
18
|
+
*/
|
|
19
|
+
export const getCached = (root: object, pathKey: string): any | undefined => {
|
|
20
|
+
const perRoot = proxyCache.get(root);
|
|
21
|
+
|
|
22
|
+
return perRoot?.get(pathKey);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Store a proxy in the cache for the given root and path.
|
|
28
|
+
*
|
|
29
|
+
* @param root - The root object
|
|
30
|
+
* @param pathKey - The path key (from pathKeyOf)
|
|
31
|
+
* @param proxy - The proxy to cache
|
|
32
|
+
*/
|
|
33
|
+
export const setCached = (root: object, pathKey: string, proxy: any): void => {
|
|
34
|
+
let perRoot = proxyCache.get(root);
|
|
35
|
+
if (!perRoot) {
|
|
36
|
+
perRoot = new Map<string, any>();
|
|
37
|
+
proxyCache.set(root, perRoot);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
perRoot.set(pathKey, proxy);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Invalidate cached proxies at and below the given base path.
|
|
46
|
+
* Optionally also invalidates the parent path (for array shrinkage).
|
|
47
|
+
*
|
|
48
|
+
* @param root - The root object
|
|
49
|
+
* @param basePath - The base path to invalidate
|
|
50
|
+
* @param alsoParentArray - Whether to also invalidate the parent path
|
|
51
|
+
*/
|
|
52
|
+
export const invalidateAt = (root: object, basePath: string[], alsoParentArray?: boolean): void => {
|
|
53
|
+
const opts = getOptions(root);
|
|
54
|
+
if (!opts.cacheProxies)
|
|
55
|
+
return;
|
|
56
|
+
|
|
57
|
+
const perRoot = proxyCache.get(root);
|
|
58
|
+
if (!perRoot)
|
|
59
|
+
return;
|
|
60
|
+
|
|
61
|
+
const base = pathKeyOf(basePath);
|
|
62
|
+
for (const k of Array.from(perRoot.keys())) {
|
|
63
|
+
if (k === base || k.startsWith(base + '\x1f'))
|
|
64
|
+
perRoot.delete(k);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (alsoParentArray) {
|
|
68
|
+
const parentKey = pathKeyOf(basePath.slice(0, -1));
|
|
69
|
+
for (const k of Array.from(perRoot.keys())) {
|
|
70
|
+
if (k === parentKey || k.startsWith(parentKey + '\x1f'))
|
|
71
|
+
perRoot.delete(k);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Clear all cached proxies for the given root.
|
|
79
|
+
*
|
|
80
|
+
* @param root - The root object
|
|
81
|
+
*/
|
|
82
|
+
export const clear = (root: object): void => {
|
|
83
|
+
const perRoot = proxyCache.get(root);
|
|
84
|
+
if (perRoot)
|
|
85
|
+
perRoot.clear();
|
|
86
|
+
};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { captureShrinkRemovals, deleteIndex, isArrayIndexDeletion } from './array-mutations.ts';
|
|
2
|
+
import { adaptMapMethod, adaptSetMethod } from './collection-adapters.ts';
|
|
3
|
+
import { computeActiveGroupId } from './grouping.ts';
|
|
4
|
+
import { getOptions } from './history.ts';
|
|
5
|
+
import { recordArrayShrinkDeletes, recordDelete, recordSet } from './history-recorder.ts';
|
|
6
|
+
import { computeAffectedListeners } from './listener-affinity.ts';
|
|
7
|
+
import { getListenerBucket } from './listener-trie.ts';
|
|
8
|
+
import { normalizeKey } from './path.ts';
|
|
9
|
+
import { pathKeyOf } from './path-key.ts';
|
|
10
|
+
import { clear as clearProxyCacheInternal, getCached, invalidateAt, setCached } from './proxy-cache.ts';
|
|
11
|
+
import { notifyListeners } from './schedule-queue.ts';
|
|
12
|
+
import type { ChangeMeta } from './types.ts';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
export interface ProxyFactoryDeps {
|
|
16
|
+
getBatchFrames: (root: object) => { marker: number; id: string; }[] | undefined;
|
|
17
|
+
setProxyRoot: (proxy: object, root: object) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProxyFactory {
|
|
21
|
+
createProxy: <O extends object>(targetObject: O, path: string[] | undefined, rootObject: object) => O;
|
|
22
|
+
invalidateCacheAt: (root: object, basePath: string[], alsoParentArray?: boolean) => void;
|
|
23
|
+
clearProxyCache: (root: object) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export const invalidateCacheAt: typeof invalidateAt = invalidateAt;
|
|
28
|
+
export const clearProxyCache: typeof clearProxyCacheInternal = clearProxyCacheInternal;
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
export const createProxyFactory = (deps: ProxyFactoryDeps): ProxyFactory => {
|
|
32
|
+
const createProxy: ProxyFactory['createProxy'] = (targetObject, path = [], rootObject) => {
|
|
33
|
+
const opts = getOptions(rootObject);
|
|
34
|
+
if (opts.cacheProxies) {
|
|
35
|
+
const pathKey = pathKeyOf(path);
|
|
36
|
+
const cached = getCached(rootObject, pathKey);
|
|
37
|
+
if (cached)
|
|
38
|
+
return cached;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const proxy = new Proxy(targetObject, {
|
|
42
|
+
get(target, prop) {
|
|
43
|
+
const result = Reflect.get(target, prop);
|
|
44
|
+
|
|
45
|
+
// Map/Set adapters: wrap mutating methods and bind non-mutators to raw target for brand checks
|
|
46
|
+
const isMap = target instanceof Map;
|
|
47
|
+
const isSet = target instanceof Set;
|
|
48
|
+
if ((isMap || isSet) && typeof result === 'function') {
|
|
49
|
+
const method = String(prop);
|
|
50
|
+
const currentPath = path.slice(); // collection lives at this path
|
|
51
|
+
|
|
52
|
+
if (isMap) {
|
|
53
|
+
const adapted = adaptMapMethod(target as Map<any, any>, currentPath, rootObject, deps, method);
|
|
54
|
+
if (adapted)
|
|
55
|
+
return adapted;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (isSet) {
|
|
59
|
+
const adapted = adaptSetMethod(target as Set<any>, currentPath, rootObject, deps, method);
|
|
60
|
+
if (adapted)
|
|
61
|
+
return adapted;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// For other methods, bind to raw target to satisfy brand checks
|
|
65
|
+
return (result as (...args: any[]) => any).bind(target);
|
|
66
|
+
}
|
|
67
|
+
if (!result || typeof result !== 'object')
|
|
68
|
+
return result;
|
|
69
|
+
|
|
70
|
+
const currentPath = [ ...path, normalizeKey(prop) ];
|
|
71
|
+
|
|
72
|
+
return createProxy(result, currentPath, rootObject);
|
|
73
|
+
},
|
|
74
|
+
set(target, prop, value) {
|
|
75
|
+
const currentPath = [ ...path, normalizeKey(prop) ];
|
|
76
|
+
const hadBefore = Reflect.has(target, prop);
|
|
77
|
+
const oldValue = Reflect.get(target, prop);
|
|
78
|
+
|
|
79
|
+
// Capture elements that will be removed if shrinking array length
|
|
80
|
+
let removedForLengthShrink: { index: number; value: any; }[] | null = null;
|
|
81
|
+
if (
|
|
82
|
+
Array.isArray(target)
|
|
83
|
+
&& normalizeKey(prop) === 'length'
|
|
84
|
+
&& typeof oldValue === 'number'
|
|
85
|
+
&& typeof value === 'number'
|
|
86
|
+
&& value < oldValue
|
|
87
|
+
)
|
|
88
|
+
removedForLengthShrink = captureShrinkRemovals(target, oldValue, value);
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
const result = Reflect.set(target, prop, value);
|
|
92
|
+
const bucket = getListenerBucket(rootObject);
|
|
93
|
+
const activeGroupId = computeActiveGroupId(rootObject, deps.getBatchFrames);
|
|
94
|
+
|
|
95
|
+
// Record change in history
|
|
96
|
+
recordSet(rootObject, currentPath, oldValue, value, hadBefore, activeGroupId);
|
|
97
|
+
|
|
98
|
+
// If we shrank array length, synthesize delete records for removed indices
|
|
99
|
+
if (removedForLengthShrink && removedForLengthShrink.length > 0)
|
|
100
|
+
recordArrayShrinkDeletes(rootObject, path, removedForLengthShrink, activeGroupId);
|
|
101
|
+
|
|
102
|
+
// Invalidate proxy cache for this path; if shrinking array length, also invalidate the array base
|
|
103
|
+
const shrinkingArray = Array.isArray(target)
|
|
104
|
+
&& normalizeKey(prop) === 'length'
|
|
105
|
+
&& typeof oldValue === 'number'
|
|
106
|
+
&& typeof value === 'number'
|
|
107
|
+
&& value < oldValue;
|
|
108
|
+
|
|
109
|
+
invalidateAt(rootObject, currentPath, shrinkingArray);
|
|
110
|
+
|
|
111
|
+
if (bucket) {
|
|
112
|
+
const affectedListeners = computeAffectedListeners(rootObject, currentPath);
|
|
113
|
+
const meta: ChangeMeta = { type: 'set', existedBefore: hadBefore, groupId: activeGroupId };
|
|
114
|
+
notifyListeners(rootObject, affectedListeners, [ currentPath, value, oldValue, meta ]);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
},
|
|
119
|
+
deleteProperty(target, prop) {
|
|
120
|
+
const key = normalizeKey(prop);
|
|
121
|
+
const currentPath = [ ...path, key ];
|
|
122
|
+
const oldValue = Reflect.get(target, prop);
|
|
123
|
+
const hadBefore = Reflect.has(target, prop);
|
|
124
|
+
let result: boolean;
|
|
125
|
+
|
|
126
|
+
// If deleting from an array by numeric index, use splice to avoid holes (parity with undo behavior)
|
|
127
|
+
if (isArrayIndexDeletion(target, key)) {
|
|
128
|
+
const idx = Number(key);
|
|
129
|
+
result = deleteIndex(rootObject, target as any[], idx);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
result = Reflect.deleteProperty(target, prop);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const bucket = getListenerBucket(rootObject);
|
|
136
|
+
const activeGroupId = computeActiveGroupId(rootObject, deps.getBatchFrames);
|
|
137
|
+
|
|
138
|
+
// Record change in history
|
|
139
|
+
recordDelete(rootObject, currentPath, oldValue, activeGroupId);
|
|
140
|
+
|
|
141
|
+
// Invalidate proxy cache for this path and, for array index splice case, also for the array base
|
|
142
|
+
const isArrayIndex = isArrayIndexDeletion(target, key);
|
|
143
|
+
invalidateAt(rootObject, currentPath, isArrayIndex);
|
|
144
|
+
|
|
145
|
+
// Notify listeners (deletes affect exact path only and descendants no longer exist)
|
|
146
|
+
if (bucket) {
|
|
147
|
+
const affectedListeners = computeAffectedListeners(rootObject, currentPath);
|
|
148
|
+
const meta: ChangeMeta = { type: 'delete', existedBefore: hadBefore, groupId: activeGroupId };
|
|
149
|
+
notifyListeners(rootObject, affectedListeners, [ currentPath, undefined, oldValue, meta ]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return result;
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
deps.setProxyRoot(proxy, rootObject);
|
|
157
|
+
|
|
158
|
+
// Store in cache if enabled
|
|
159
|
+
if (opts.cacheProxies) {
|
|
160
|
+
const pathKey = pathKeyOf(path);
|
|
161
|
+
setCached(rootObject, pathKey, proxy);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return proxy;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return { createProxy, invalidateCacheAt, clearProxyCache };
|
|
168
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { ChangeListener, ChangeMeta, ListenerOptions, QueuedCall } from './types.ts';
|
|
2
|
+
|
|
3
|
+
// Per-root pause state and queued notifications
|
|
4
|
+
const pauseState: WeakMap<object, { paused: boolean; queue: QueuedCall[]; }> = new WeakMap();
|
|
5
|
+
|
|
6
|
+
const isPaused = (root: object): boolean => (pauseState.get(root)?.paused === true);
|
|
7
|
+
|
|
8
|
+
const enqueue = (root: object, call: QueuedCall) => {
|
|
9
|
+
let st = pauseState.get(root);
|
|
10
|
+
if (!st) {
|
|
11
|
+
st = { paused: true, queue: [] };
|
|
12
|
+
pauseState.set(root, st);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
st.queue.push(call);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const notifyListeners = (
|
|
19
|
+
root: object,
|
|
20
|
+
listeners: Set<ChangeListener>,
|
|
21
|
+
args: [ string[], any, any, ChangeMeta | undefined ],
|
|
22
|
+
): void => {
|
|
23
|
+
if (listeners.size === 0)
|
|
24
|
+
return;
|
|
25
|
+
|
|
26
|
+
if (isPaused(root)) {
|
|
27
|
+
listeners.forEach(listener => enqueue(root, { listener, args }));
|
|
28
|
+
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
listeners.forEach(listener => listener(...args));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const pause = (obj: object): void => {
|
|
36
|
+
const st = pauseState.get(obj);
|
|
37
|
+
if (st)
|
|
38
|
+
st.paused = true;
|
|
39
|
+
else
|
|
40
|
+
pauseState.set(obj, { paused: true, queue: [] });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const resume = (obj: object): void => {
|
|
44
|
+
const st = pauseState.get(obj);
|
|
45
|
+
if (!st)
|
|
46
|
+
return;
|
|
47
|
+
|
|
48
|
+
// Deliver queued notifications in FIFO order
|
|
49
|
+
const q = st.queue.splice(0, st.queue.length);
|
|
50
|
+
st.paused = false;
|
|
51
|
+
for (const { listener, args } of q)
|
|
52
|
+
listener(...args);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const flush = (obj: object): void => {
|
|
56
|
+
const st = pauseState.get(obj);
|
|
57
|
+
if (!st || st.queue.length === 0)
|
|
58
|
+
return;
|
|
59
|
+
|
|
60
|
+
const q = st.queue.splice(0, st.queue.length);
|
|
61
|
+
for (const { listener, args } of q)
|
|
62
|
+
listener(...args);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Wrap a listener with QoL options: once, debounce, throttle, schedule=microtask
|
|
66
|
+
export const buildEffectiveListener = (
|
|
67
|
+
listener: ChangeListener,
|
|
68
|
+
options?: ListenerOptions,
|
|
69
|
+
): { effective: ChangeListener; setUnsubscribe: (fn: () => void) => void; } => {
|
|
70
|
+
const opts = options ?? {};
|
|
71
|
+
let unsubscribe: (() => void) | undefined;
|
|
72
|
+
const setUnsubscribe = (fn: () => void) => { unsubscribe = fn; };
|
|
73
|
+
|
|
74
|
+
const scheduleInvoke = (fn: () => void) => {
|
|
75
|
+
if (opts.schedule === 'microtask')
|
|
76
|
+
queueMicrotask(fn);
|
|
77
|
+
else
|
|
78
|
+
fn();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let calledOnce = false;
|
|
82
|
+
let debounceTimer: any = null;
|
|
83
|
+
let throttleTimer: any = null;
|
|
84
|
+
let nextAllowed = 0;
|
|
85
|
+
let pendingArgs: [string[], any, any, ChangeMeta | undefined] | null = null;
|
|
86
|
+
|
|
87
|
+
const invoke = (args: [string[], any, any, ChangeMeta | undefined]) => {
|
|
88
|
+
if (opts.once && calledOnce)
|
|
89
|
+
return;
|
|
90
|
+
|
|
91
|
+
scheduleInvoke(() => {
|
|
92
|
+
listener(...args);
|
|
93
|
+
if (opts.once) {
|
|
94
|
+
calledOnce = true;
|
|
95
|
+
if (unsubscribe)
|
|
96
|
+
unsubscribe();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const effective: ChangeListener = (path, newValue, oldValue, meta) => {
|
|
102
|
+
const args: [string[], any, any, ChangeMeta | undefined] = [ path, newValue, oldValue, meta ];
|
|
103
|
+
if (opts.debounceMs != null && opts.debounceMs >= 0) {
|
|
104
|
+
pendingArgs = args;
|
|
105
|
+
if (debounceTimer)
|
|
106
|
+
clearTimeout(debounceTimer);
|
|
107
|
+
|
|
108
|
+
debounceTimer = setTimeout(() => {
|
|
109
|
+
const a = pendingArgs!;
|
|
110
|
+
pendingArgs = null;
|
|
111
|
+
invoke(a);
|
|
112
|
+
}, opts.debounceMs);
|
|
113
|
+
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (opts.throttleMs != null && opts.throttleMs > 0) {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
if (now >= nextAllowed) {
|
|
120
|
+
nextAllowed = now + opts.throttleMs;
|
|
121
|
+
invoke(args);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
pendingArgs = args;
|
|
125
|
+
if (!throttleTimer) {
|
|
126
|
+
throttleTimer = setTimeout(() => {
|
|
127
|
+
throttleTimer = null;
|
|
128
|
+
const a = pendingArgs!;
|
|
129
|
+
pendingArgs = null;
|
|
130
|
+
nextAllowed = Date.now() + (opts.throttleMs ?? 0);
|
|
131
|
+
invoke(a);
|
|
132
|
+
}, Math.max(0, nextAllowed - now));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// default immediate
|
|
140
|
+
invoke(args);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return { effective, setUnsubscribe };
|
|
144
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getOptions } from './history.ts';
|
|
2
|
+
import { normalizeKey } from './path.ts';
|
|
3
|
+
import type { DiffRecord } from './types.ts';
|
|
4
|
+
|
|
5
|
+
// Original snapshot for diff/isPristine
|
|
6
|
+
export const originalSnapshotCache: WeakMap<object, any> = new WeakMap();
|
|
7
|
+
|
|
8
|
+
// Deep clone utility honoring options.clone and falling back to structuredClone
|
|
9
|
+
export const deepClone = <T>(v: T): T => {
|
|
10
|
+
try {
|
|
11
|
+
return structuredClone(v) as T;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return v;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const cloneWithOptions = <T>(root: object, v: T): T => {
|
|
19
|
+
const opts = getOptions(root);
|
|
20
|
+
if (opts.clone) {
|
|
21
|
+
try {
|
|
22
|
+
return opts.clone(v);
|
|
23
|
+
}
|
|
24
|
+
catch { /* fall through */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return deepClone(v);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null;
|
|
31
|
+
|
|
32
|
+
export const diffValues = (
|
|
33
|
+
a: any,
|
|
34
|
+
b: any,
|
|
35
|
+
path: string[],
|
|
36
|
+
out: DiffRecord[],
|
|
37
|
+
root: object,
|
|
38
|
+
seenParam?: WeakMap<object, object>,
|
|
39
|
+
): void => {
|
|
40
|
+
const opts = getOptions(root);
|
|
41
|
+
const equal = opts.compare ?? ((x: any, y: any) => Object.is(x, y));
|
|
42
|
+
const filter = opts.diffFilter;
|
|
43
|
+
const seen = seenParam ?? new WeakMap<object, object>();
|
|
44
|
+
|
|
45
|
+
const f = filter ? filter(path) : true;
|
|
46
|
+
if (f === false)
|
|
47
|
+
return; // skip subtree
|
|
48
|
+
if (f === 'shallow') {
|
|
49
|
+
if (!equal(a, b, path))
|
|
50
|
+
out.push({ path: path.slice(), kind: 'changed', oldValue: a, newValue: b });
|
|
51
|
+
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (equal(a, b, path))
|
|
56
|
+
return;
|
|
57
|
+
|
|
58
|
+
if (isObject(a) && isObject(b)) {
|
|
59
|
+
if (seen.get(a as object) === (b as object))
|
|
60
|
+
return;
|
|
61
|
+
|
|
62
|
+
seen.set(a as object, b as object);
|
|
63
|
+
|
|
64
|
+
const aKeyMap: Map<string, PropertyKey> = new Map();
|
|
65
|
+
for (const k of Reflect.ownKeys(a))
|
|
66
|
+
aKeyMap.set(normalizeKey(k), k);
|
|
67
|
+
const bKeyMap: Map<string, PropertyKey> = new Map();
|
|
68
|
+
for (const k of Reflect.ownKeys(b))
|
|
69
|
+
bKeyMap.set(normalizeKey(k), k);
|
|
70
|
+
|
|
71
|
+
const aKeys = new Set(aKeyMap.keys());
|
|
72
|
+
const bKeys = new Set(bKeyMap.keys());
|
|
73
|
+
|
|
74
|
+
for (const nk of aKeys) {
|
|
75
|
+
const nextPath = [ ...path, nk ];
|
|
76
|
+
if (!bKeys.has(nk))
|
|
77
|
+
out.push({ path: nextPath, kind: 'removed', oldValue: (a as any)[aKeyMap.get(nk)!] });
|
|
78
|
+
else
|
|
79
|
+
diffValues((a as any)[aKeyMap.get(nk)!], (b as any)[bKeyMap.get(nk)!], nextPath, out, root, seen);
|
|
80
|
+
}
|
|
81
|
+
for (const nk of bKeys) {
|
|
82
|
+
if (!aKeys.has(nk))
|
|
83
|
+
out.push({ path: [ ...path, nk ], kind: 'added', newValue: (b as any)[bKeyMap.get(nk)!] });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
out.push({ path: path.slice(), kind: 'changed', oldValue: a, newValue: b });
|
|
90
|
+
};
|
package/src/symbol-id.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Stable per-process symbol identity mapping used for path normalization.
|
|
3
|
+
* Distinct symbols map to unique ids (sym#N), avoiding description-based collisions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const symbolIds: WeakMap<symbol, string> = new WeakMap();
|
|
7
|
+
let symbolCounter = 0;
|
|
8
|
+
|
|
9
|
+
export const getSymbolId = (s: symbol): string => {
|
|
10
|
+
let id = symbolIds.get(s);
|
|
11
|
+
if (!id) {
|
|
12
|
+
id = `sym#${ ++symbolCounter }`;
|
|
13
|
+
symbolIds.set(s, id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return id;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const normalizePropertyKey = (prop: PropertyKey): string =>
|
|
20
|
+
typeof prop === 'symbol' ? getSymbolId(prop) : String(prop);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Shared types for observe subsystem (no runtime code)
|
|
2
|
+
|
|
3
|
+
export interface ChangeMeta {
|
|
4
|
+
type: 'set' | 'delete';
|
|
5
|
+
existedBefore?: boolean;
|
|
6
|
+
groupId?: string;
|
|
7
|
+
// Collection metadata (for Map/Set)
|
|
8
|
+
collection?: 'map' | 'set';
|
|
9
|
+
key?: any;
|
|
10
|
+
}
|
|
11
|
+
export type ChangeListener = (path: string[], newValue: any, oldValue: any, meta?: ChangeMeta) => void;
|
|
12
|
+
|
|
13
|
+
export type PathSelector<T> = (object: T) => any;
|
|
14
|
+
|
|
15
|
+
export type PathMode = 'exact' | 'up' | 'down';
|
|
16
|
+
|
|
17
|
+
export interface PathTrieNode {
|
|
18
|
+
children: Map<string, PathTrieNode>;
|
|
19
|
+
modes: Map<PathMode, Set<ChangeListener>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListenerBucket {
|
|
23
|
+
global: Set<ChangeListener>;
|
|
24
|
+
trie: PathTrieNode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ListenerOptions {
|
|
28
|
+
once?: boolean;
|
|
29
|
+
debounceMs?: number;
|
|
30
|
+
throttleMs?: number;
|
|
31
|
+
schedule?: 'sync' | 'microtask';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- Change history (for undo/diff) ---
|
|
35
|
+
export type ChangeType = 'set' | 'delete';
|
|
36
|
+
export interface ChangeRecord {
|
|
37
|
+
path: string[];
|
|
38
|
+
type: ChangeType;
|
|
39
|
+
oldValue: any;
|
|
40
|
+
newValue: any;
|
|
41
|
+
timestamp: number;
|
|
42
|
+
existedBefore?: boolean;
|
|
43
|
+
groupId?: string;
|
|
44
|
+
// Collection metadata (for Map/Set adapters)
|
|
45
|
+
collection?: 'map' | 'set';
|
|
46
|
+
key?: any; // Map key (or Set entry when needed)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Diff ---
|
|
50
|
+
export type DiffKind = 'added' | 'removed' | 'changed';
|
|
51
|
+
export interface DiffRecord {
|
|
52
|
+
path: string[];
|
|
53
|
+
kind: DiffKind;
|
|
54
|
+
oldValue?: any;
|
|
55
|
+
newValue?: any;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Internal helper for pause queue
|
|
59
|
+
export interface QueuedCall { listener: ChangeListener; args: [ string[], any, any, ChangeMeta | undefined ]; }
|