@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.
Files changed (113) hide show
  1. package/README.md +563 -0
  2. package/dist/api-methods.d.ts +28 -0
  3. package/dist/api-methods.d.ts.map +1 -0
  4. package/dist/api-methods.js +206 -0
  5. package/dist/api-methods.js.map +1 -0
  6. package/dist/api.d.ts +12 -0
  7. package/dist/api.d.ts.map +1 -0
  8. package/dist/api.js +30 -0
  9. package/dist/api.js.map +1 -0
  10. package/dist/array-mutations.d.ts +31 -0
  11. package/dist/array-mutations.d.ts.map +1 -0
  12. package/dist/array-mutations.js +50 -0
  13. package/dist/array-mutations.js.map +1 -0
  14. package/dist/batch-transaction.d.ts +25 -0
  15. package/dist/batch-transaction.d.ts.map +1 -0
  16. package/dist/batch-transaction.js +138 -0
  17. package/dist/batch-transaction.js.map +1 -0
  18. package/dist/chronicle.d.ts +41 -0
  19. package/dist/chronicle.d.ts.map +1 -0
  20. package/dist/chronicle.js +40 -0
  21. package/dist/chronicle.js.map +1 -0
  22. package/dist/collection-adapters.d.ts +29 -0
  23. package/dist/collection-adapters.d.ts.map +1 -0
  24. package/dist/collection-adapters.js +184 -0
  25. package/dist/collection-adapters.js.map +1 -0
  26. package/dist/config.d.ts +7 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +11 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/grouping.d.ts +17 -0
  31. package/dist/grouping.d.ts.map +1 -0
  32. package/dist/grouping.js +35 -0
  33. package/dist/grouping.js.map +1 -0
  34. package/dist/history-recorder.d.ts +39 -0
  35. package/dist/history-recorder.d.ts.map +1 -0
  36. package/dist/history-recorder.js +112 -0
  37. package/dist/history-recorder.js.map +1 -0
  38. package/dist/history.d.ts +29 -0
  39. package/dist/history.d.ts.map +1 -0
  40. package/dist/history.js +47 -0
  41. package/dist/history.js.map +1 -0
  42. package/dist/listener-affinity.d.ts +16 -0
  43. package/dist/listener-affinity.d.ts.map +1 -0
  44. package/dist/listener-affinity.js +58 -0
  45. package/dist/listener-affinity.js.map +1 -0
  46. package/dist/listener-trie.d.ts +10 -0
  47. package/dist/listener-trie.d.ts.map +1 -0
  48. package/dist/listener-trie.js +83 -0
  49. package/dist/listener-trie.js.map +1 -0
  50. package/dist/nameof.d.ts +12 -0
  51. package/dist/nameof.d.ts.map +1 -0
  52. package/dist/nameof.js +30 -0
  53. package/dist/nameof.js.map +1 -0
  54. package/dist/path-key.d.ts +11 -0
  55. package/dist/path-key.d.ts.map +1 -0
  56. package/dist/path-key.js +11 -0
  57. package/dist/path-key.js.map +1 -0
  58. package/dist/path.d.ts +7 -0
  59. package/dist/path.d.ts.map +1 -0
  60. package/dist/path.js +53 -0
  61. package/dist/path.js.map +1 -0
  62. package/dist/proxy-cache.d.ts +32 -0
  63. package/dist/proxy-cache.d.ts.map +1 -0
  64. package/dist/proxy-cache.js +72 -0
  65. package/dist/proxy-cache.js.map +1 -0
  66. package/dist/proxy-factory.d.ts +17 -0
  67. package/dist/proxy-factory.d.ts.map +1 -0
  68. package/dist/proxy-factory.js +124 -0
  69. package/dist/proxy-factory.js.map +1 -0
  70. package/dist/schedule-queue.d.ts +10 -0
  71. package/dist/schedule-queue.d.ts.map +1 -0
  72. package/dist/schedule-queue.js +112 -0
  73. package/dist/schedule-queue.js.map +1 -0
  74. package/dist/snapshot-diff.d.ts +6 -0
  75. package/dist/snapshot-diff.d.ts.map +1 -0
  76. package/dist/snapshot-diff.js +67 -0
  77. package/dist/snapshot-diff.js.map +1 -0
  78. package/dist/symbol-id.d.ts +3 -0
  79. package/dist/symbol-id.d.ts.map +1 -0
  80. package/dist/symbol-id.js +16 -0
  81. package/dist/symbol-id.js.map +1 -0
  82. package/dist/types.d.ts +48 -0
  83. package/dist/types.d.ts.map +1 -0
  84. package/dist/types.js +3 -0
  85. package/dist/types.js.map +1 -0
  86. package/dist/undo-redo.d.ts +12 -0
  87. package/dist/undo-redo.d.ts.map +1 -0
  88. package/dist/undo-redo.js +216 -0
  89. package/dist/undo-redo.js.map +1 -0
  90. package/package.json +45 -0
  91. package/src/api-methods.ts +292 -0
  92. package/src/api.ts +53 -0
  93. package/src/array-mutations.ts +64 -0
  94. package/src/batch-transaction.ts +183 -0
  95. package/src/chronicle.ts +100 -0
  96. package/src/collection-adapters.ts +224 -0
  97. package/src/config.ts +16 -0
  98. package/src/grouping.ts +47 -0
  99. package/src/history-recorder.ts +145 -0
  100. package/src/history.ts +75 -0
  101. package/src/listener-affinity.ts +69 -0
  102. package/src/listener-trie.ts +103 -0
  103. package/src/nameof.ts +42 -0
  104. package/src/path-key.ts +10 -0
  105. package/src/path.ts +69 -0
  106. package/src/proxy-cache.ts +86 -0
  107. package/src/proxy-factory.ts +168 -0
  108. package/src/schedule-queue.ts +144 -0
  109. package/src/snapshot-diff.ts +90 -0
  110. package/src/symbol-id.ts +20 -0
  111. package/src/tsconfig.json +3 -0
  112. package/src/types.ts +59 -0
  113. 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
+ };
@@ -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
+ };
@@ -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);
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@arcmantle/tsconfig",
3
+ }
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 ]; }