@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
@@ -0,0 +1,224 @@
1
+ import { computeActiveGroupId } from './grouping.ts';
2
+ import { ensureHistory, getOptions, trimHistoryByGroups } from './history.ts';
3
+ import { computeAffectedListeners } from './listener-affinity.ts';
4
+ import { notifyListeners } from './schedule-queue.ts';
5
+ import type { ChangeMeta, ChangeRecord } from './types.ts';
6
+ import { clearRedoCache, isSuspended } from './undo-redo.ts';
7
+
8
+
9
+ export interface CollectionAdapterDeps {
10
+ getBatchFrames: (root: object) => { marker: number; id: string; }[] | undefined;
11
+ }
12
+
13
+
14
+ /**
15
+ * Create a history-and-notify callback for Map/Set mutations.
16
+ * This shared helper records the change and notifies listeners.
17
+ */
18
+ const createRecordAndNotify = (
19
+ rootObject: object,
20
+ currentPath: string[],
21
+ ) => (rec: ChangeRecord, newValForListener: any, oldValForListener: any): void => {
22
+ // Record in history (Map/Set records have collection/key metadata, so push directly)
23
+ if (!isSuspended(rootObject)) {
24
+ const history = ensureHistory(rootObject);
25
+ clearRedoCache(rootObject);
26
+ const cfg = getOptions(rootObject);
27
+ if (!cfg?.filter || cfg.filter(rec))
28
+ history.push(rec);
29
+ if (cfg && typeof cfg.maxHistory === 'number')
30
+ trimHistoryByGroups(history, cfg.maxHistory);
31
+ }
32
+
33
+ // Notify listeners
34
+ const affected = computeAffectedListeners(rootObject, currentPath);
35
+ if (affected.size > 0) {
36
+ const meta: ChangeMeta = { type: rec.type, existedBefore: rec.existedBefore, groupId: rec.groupId };
37
+ notifyListeners(rootObject, affected, [ currentPath, newValForListener, oldValForListener, meta ]);
38
+ }
39
+ };
40
+
41
+
42
+ /**
43
+ * Wrap Map mutating methods to record changes and notify listeners.
44
+ *
45
+ * @param target - The Map instance
46
+ * @param currentPath - The path to the Map
47
+ * @param rootObject - The root object
48
+ * @param deps - Dependencies (getBatchFrames)
49
+ * @param method - The method name being accessed
50
+ * @returns Wrapped method or undefined if not a mutating method
51
+ */
52
+ export const adaptMapMethod = (
53
+ target: Map<any, any>,
54
+ currentPath: string[],
55
+ rootObject: object,
56
+ deps: CollectionAdapterDeps,
57
+ method: string,
58
+ ): ((...args: any[]) => any) | undefined => {
59
+ const recordHistoryAndNotify = createRecordAndNotify(rootObject, currentPath);
60
+
61
+ if (method === 'set') {
62
+ return function(this: any, key: any, value: any) {
63
+ const m = target;
64
+ const had = m.has(key);
65
+ const oldV = had ? m.get(key) : undefined;
66
+ m.set(key, value);
67
+
68
+ const rec: ChangeRecord = {
69
+ path: currentPath.slice(),
70
+ type: 'set',
71
+ oldValue: oldV,
72
+ newValue: value,
73
+ timestamp: Date.now(),
74
+ existedBefore: had,
75
+ groupId: computeActiveGroupId(rootObject, deps.getBatchFrames),
76
+ collection: 'map',
77
+ key,
78
+ };
79
+ recordHistoryAndNotify(rec, value, oldV);
80
+
81
+ return this;
82
+ };
83
+ }
84
+
85
+ if (method === 'delete') {
86
+ return function(this: any, key: any) {
87
+ const m = target;
88
+ const had = m.has(key);
89
+ const oldV = had ? m.get(key) : undefined;
90
+ const res = m.delete(key) as boolean;
91
+ if (had) {
92
+ const rec: ChangeRecord = {
93
+ path: currentPath.slice(),
94
+ type: 'delete',
95
+ oldValue: oldV,
96
+ newValue: undefined,
97
+ timestamp: Date.now(),
98
+ groupId: computeActiveGroupId(rootObject, deps.getBatchFrames),
99
+ collection: 'map',
100
+ key,
101
+ };
102
+ recordHistoryAndNotify(rec, undefined, oldV);
103
+ }
104
+
105
+ return res;
106
+ };
107
+ }
108
+
109
+ if (method === 'clear') {
110
+ return function(this: any) {
111
+ const m = target;
112
+ const entries = Array.from(m.entries()) as [ any, any ][];
113
+ const gid = computeActiveGroupId(rootObject, deps.getBatchFrames);
114
+ m.clear();
115
+ for (const [ k, v ] of entries) {
116
+ const rec: ChangeRecord = {
117
+ path: currentPath.slice(),
118
+ type: 'delete',
119
+ oldValue: v,
120
+ newValue: undefined,
121
+ timestamp: Date.now(),
122
+ groupId: gid,
123
+ collection: 'map',
124
+ key: k,
125
+ };
126
+ recordHistoryAndNotify(rec, undefined, v);
127
+ }
128
+ };
129
+ }
130
+
131
+ return undefined;
132
+ };
133
+
134
+
135
+ /**
136
+ * Wrap Set mutating methods to record changes and notify listeners.
137
+ *
138
+ * @param target - The Set instance
139
+ * @param currentPath - The path to the Set
140
+ * @param rootObject - The root object
141
+ * @param deps - Dependencies (getBatchFrames)
142
+ * @param method - The method name being accessed
143
+ * @returns Wrapped method or undefined if not a mutating method
144
+ */
145
+ export const adaptSetMethod = (
146
+ target: Set<any>,
147
+ currentPath: string[],
148
+ rootObject: object,
149
+ deps: CollectionAdapterDeps,
150
+ method: string,
151
+ ): ((...args: any[]) => any) | undefined => {
152
+ const recordHistoryAndNotify = createRecordAndNotify(rootObject, currentPath);
153
+
154
+ if (method === 'add') {
155
+ return function(this: any, value: any) {
156
+ const s = target;
157
+ const had = s.has(value);
158
+ s.add(value);
159
+ if (!had) {
160
+ const rec: ChangeRecord = {
161
+ path: currentPath.slice(),
162
+ type: 'set',
163
+ oldValue: undefined,
164
+ newValue: value,
165
+ timestamp: Date.now(),
166
+ existedBefore: false,
167
+ groupId: computeActiveGroupId(rootObject, deps.getBatchFrames),
168
+ collection: 'set',
169
+ key: value,
170
+ };
171
+ recordHistoryAndNotify(rec, value, undefined);
172
+ }
173
+
174
+ return this; // chaining
175
+ };
176
+ }
177
+
178
+ if (method === 'delete') {
179
+ return function(this: any, value: any) {
180
+ const s = target;
181
+ const had = s.has(value);
182
+ const res = s.delete(value) as boolean;
183
+ if (had) {
184
+ const rec: ChangeRecord = {
185
+ path: currentPath.slice(),
186
+ type: 'delete',
187
+ oldValue: value,
188
+ newValue: undefined,
189
+ timestamp: Date.now(),
190
+ groupId: computeActiveGroupId(rootObject, deps.getBatchFrames),
191
+ collection: 'set',
192
+ key: value,
193
+ };
194
+ recordHistoryAndNotify(rec, undefined, value);
195
+ }
196
+
197
+ return res; // boolean
198
+ };
199
+ }
200
+
201
+ if (method === 'clear') {
202
+ return function(this: any) {
203
+ const s = target;
204
+ const values = Array.from(s.values()) as any[];
205
+ const gid = computeActiveGroupId(rootObject, deps.getBatchFrames);
206
+ s.clear();
207
+ for (const v of values) {
208
+ const rec: ChangeRecord = {
209
+ path: currentPath.slice(),
210
+ type: 'delete',
211
+ oldValue: v,
212
+ newValue: undefined,
213
+ timestamp: Date.now(),
214
+ groupId: gid,
215
+ collection: 'set',
216
+ key: v,
217
+ };
218
+ recordHistoryAndNotify(rec, undefined, v);
219
+ }
220
+ };
221
+ }
222
+
223
+ return undefined;
224
+ };
package/src/config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { ChronicleOptions } from './history.ts';
2
+ import { clearLastUngrouped, getOptions, setOptions } from './history.ts';
3
+
4
+
5
+ export type ConfigureOptions = ChronicleOptions;
6
+
7
+
8
+ /**
9
+ * Configure per-root observe behavior by merging options and managing mergeUngrouped window reset.
10
+ */
11
+ export const configureRoot = (root: object, options: ConfigureOptions): void => {
12
+ const prev = getOptions(root) ?? {};
13
+ setOptions(root, { ...prev, ...options });
14
+ if (!options.mergeUngrouped)
15
+ clearLastUngrouped(root);
16
+ };
@@ -0,0 +1,47 @@
1
+ import { clearLastUngrouped, getLastUngrouped, getOptions, nextGroupId, setLastUngrouped } from './history.ts';
2
+
3
+
4
+ /**
5
+ * Compute the active group ID for a change.
6
+ *
7
+ * If inside a batch, uses the current batch frame's ID.
8
+ * Otherwise, if mergeUngrouped is enabled and within the merge window,
9
+ * reuses the last ungrouped ID. Otherwise generates a new group ID.
10
+ *
11
+ * @param root - The root object
12
+ * @param getBatchFrames - Function to get batch frames for the root
13
+ * @param nowProvider - Function to get current timestamp (defaults to Date.now)
14
+ * @returns The group ID to use for the current change
15
+ */
16
+ export const computeActiveGroupId = (
17
+ root: object,
18
+ getBatchFrames: (root: object) => { marker: number; id: string; }[] | undefined,
19
+ nowProvider: () => number = Date.now,
20
+ ): string => {
21
+ const batchFrames = getBatchFrames(root);
22
+ if (batchFrames && batchFrames.length > 0)
23
+ return batchFrames[batchFrames.length - 1]!.id;
24
+
25
+ const opts = getOptions(root);
26
+ if (opts && opts.mergeUngrouped) {
27
+ const now = nowProvider();
28
+ const prev = getLastUngrouped(root);
29
+ const within = opts.mergeWindowMs == null
30
+ || (prev ? (now - prev.at) <= opts.mergeWindowMs : false);
31
+
32
+ if (prev && within) {
33
+ setLastUngrouped(root, { id: prev.id, at: now });
34
+
35
+ return prev.id;
36
+ }
37
+
38
+ const gid = nextGroupId(root);
39
+ setLastUngrouped(root, { id: gid, at: now });
40
+
41
+ return gid;
42
+ }
43
+
44
+ clearLastUngrouped(root);
45
+
46
+ return nextGroupId(root);
47
+ };
@@ -0,0 +1,145 @@
1
+ import { ensureHistory, getOptions, trimHistoryByGroups } from './history.ts';
2
+ import type { ChangeRecord } from './types.ts';
3
+ import { clearRedoCache, isSuspended } from './undo-redo.ts';
4
+
5
+
6
+ /**
7
+ * Record a 'set' operation in history with filtering, compaction, and trimming.
8
+ *
9
+ * Handles optional compaction of consecutive sets on the same path within the same group,
10
+ * avoiding compaction of array indices and length properties.
11
+ *
12
+ * @param root - The root object
13
+ * @param path - The path where the set occurred
14
+ * @param oldValue - The previous value
15
+ * @param newValue - The new value
16
+ * @param existedBefore - Whether the property existed before
17
+ * @param groupId - The group ID for this change
18
+ */
19
+ export const recordSet = (
20
+ root: object,
21
+ path: string[],
22
+ oldValue: any,
23
+ newValue: any,
24
+ existedBefore: boolean,
25
+ groupId: string,
26
+ ): void => {
27
+ if (isSuspended(root))
28
+ return;
29
+
30
+ const history = ensureHistory(root);
31
+ clearRedoCache(root);
32
+ const cfg = getOptions(root);
33
+
34
+ const rec: ChangeRecord = {
35
+ path: path.slice(),
36
+ type: 'set',
37
+ oldValue,
38
+ newValue,
39
+ timestamp: Date.now(),
40
+ existedBefore,
41
+ groupId,
42
+ };
43
+
44
+ if (!cfg?.filter || cfg.filter(rec))
45
+ history.push(rec);
46
+
47
+ // Optional compaction: merge consecutive sets on the same path within the same group
48
+ if (cfg && cfg.compactConsecutiveSamePath && history.length >= 2) {
49
+ const a = history[history.length - 2]!;
50
+ const b = history[history.length - 1]!;
51
+ const sameGroup = (a.groupId ?? `__g#${ history.length - 2 }`) === (b.groupId ?? `__g#${ history.length - 1 }`);
52
+ const samePath = a.path.length === b.path.length && a.path.every((seg, i) => seg === b.path[i]);
53
+ const isSetSet = a.type === 'set' && b.type === 'set';
54
+ // Avoid compacting array index updates and length changes
55
+ const lastSeg = b.path[b.path.length - 1]!;
56
+ const isArrayIndex = /^(?:0|[1-9]\d*)$/.test(lastSeg);
57
+ const isLengthProp = lastSeg === 'length';
58
+ if (sameGroup && samePath && isSetSet && !isArrayIndex && !isLengthProp) {
59
+ // Merge: keep 'a' with oldValue from original and update newValue/timestamp from 'b'; drop 'b'
60
+ a.newValue = b.newValue;
61
+ a.timestamp = b.timestamp;
62
+ history.pop();
63
+ }
64
+ }
65
+
66
+ // Enforce maxHistory by trimming whole groups from the front
67
+ if (cfg && typeof cfg.maxHistory === 'number')
68
+ trimHistoryByGroups(history, cfg.maxHistory);
69
+ };
70
+
71
+ /**
72
+ * Record a 'delete' operation in history with filtering and trimming.
73
+ *
74
+ * @param root - The root object
75
+ * @param path - The path where the delete occurred
76
+ * @param oldValue - The value that was deleted
77
+ * @param groupId - The group ID for this change
78
+ */
79
+ export const recordDelete = (
80
+ root: object,
81
+ path: string[],
82
+ oldValue: any,
83
+ groupId: string,
84
+ ): void => {
85
+ if (isSuspended(root))
86
+ return;
87
+
88
+ const history = ensureHistory(root);
89
+ clearRedoCache(root);
90
+ const cfg = getOptions(root);
91
+
92
+ const rec: ChangeRecord = {
93
+ path: path.slice(),
94
+ type: 'delete',
95
+ oldValue,
96
+ newValue: undefined,
97
+ timestamp: Date.now(),
98
+ groupId,
99
+ };
100
+
101
+ if (!cfg?.filter || cfg.filter(rec))
102
+ history.push(rec);
103
+
104
+ // Enforce maxHistory by trimming whole groups from the front
105
+ if (cfg && typeof cfg.maxHistory === 'number')
106
+ trimHistoryByGroups(history, cfg.maxHistory);
107
+ };
108
+
109
+ /**
110
+ * Record delete operations for array elements removed by length shrinkage.
111
+ *
112
+ * Used when array.length is decreased, synthesizing delete records for removed indices.
113
+ * Does not apply compaction or other optimizations - just records the deletes.
114
+ *
115
+ * @param root - The root object
116
+ * @param basePath - The path to the array (not including indices)
117
+ * @param removed - Array of {index, value} pairs that were removed
118
+ * @param groupId - The group ID for these changes
119
+ */
120
+ export const recordArrayShrinkDeletes = (
121
+ root: object,
122
+ basePath: string[],
123
+ removed: { index: number; value: any; }[],
124
+ groupId: string,
125
+ ): void => {
126
+ if (isSuspended(root) || removed.length === 0)
127
+ return;
128
+
129
+ const history = ensureHistory(root);
130
+ const cfg = getOptions(root);
131
+
132
+ for (const { index, value: oldVal } of removed) {
133
+ const rec: ChangeRecord = {
134
+ path: [ ...basePath, String(index) ],
135
+ type: 'delete',
136
+ oldValue: oldVal,
137
+ newValue: undefined,
138
+ timestamp: Date.now(),
139
+ groupId,
140
+ };
141
+
142
+ if (!cfg?.filter || cfg.filter(rec))
143
+ history.push(rec);
144
+ }
145
+ };
package/src/history.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { ChangeRecord } from './types.ts';
2
+
3
+ // History records per root
4
+ const historyCache: WeakMap<object, ChangeRecord[]> = new WeakMap();
5
+
6
+ // Group/merge state per root
7
+ const groupCounter: WeakMap<object, number> = new WeakMap();
8
+ const lastUngrouped: WeakMap<object, { id: string; at: number; }> = new WeakMap();
9
+
10
+ // Observe options per root
11
+ export interface ChronicleOptions {
12
+ mergeUngrouped?: boolean;
13
+ mergeWindowMs?: number;
14
+ compactConsecutiveSamePath?: boolean;
15
+ maxHistory?: number;
16
+ filter?: (record: ChangeRecord) => boolean;
17
+ clone?: (value: any) => any;
18
+ compare?: (a: any, b: any, path: string[]) => boolean; // true => equal
19
+ diffFilter?: (path: string[]) => boolean | 'shallow';
20
+ cacheProxies?: boolean;
21
+ }
22
+
23
+ const optionsCache: WeakMap<object, ChronicleOptions> = new WeakMap();
24
+
25
+ export const ensureHistory = (root: object): ChangeRecord[] => {
26
+ let h = historyCache.get(root);
27
+ if (!h) {
28
+ h = [];
29
+ historyCache.set(root, h);
30
+ }
31
+
32
+ return h;
33
+ };
34
+
35
+ export const historyGet = (root: object): ChangeRecord[] | undefined => historyCache.get(root);
36
+ export const historyDelete = (root: object): void => { historyCache.delete(root); };
37
+
38
+ // Trim history by removing whole groups from the front until length <= max.
39
+ // This keeps undoGroups coherent and avoids splitting groups.
40
+ export const trimHistoryByGroups = (history: ChangeRecord[], max: number): void => {
41
+ if (!(typeof max === 'number') || max < 0)
42
+ return;
43
+
44
+ if (history.length <= max)
45
+ return;
46
+
47
+ let removeCount = 0;
48
+ let i = 0;
49
+ while (history.length - removeCount > max && i < history.length) {
50
+ const gid = history[i]!.groupId ?? `__g#${ i }`;
51
+ let j = i;
52
+ while (j < history.length && (history[j]!.groupId ?? `__g#${ j }`) === gid)
53
+ j++;
54
+
55
+ removeCount += (j - i);
56
+ i = j;
57
+ }
58
+
59
+ if (removeCount > 0)
60
+ history.splice(0, removeCount);
61
+ };
62
+
63
+ export const nextGroupId = (root: object): string => {
64
+ const n = (groupCounter.get(root) ?? 0) + 1;
65
+ groupCounter.set(root, n);
66
+
67
+ return `g${ n }`;
68
+ };
69
+
70
+ export const getOptions = (root: object): ChronicleOptions => optionsCache.get(root) ?? {};
71
+ export const setOptions = (root: object, options: ChronicleOptions): void => { optionsCache.set(root, options); };
72
+
73
+ export const getLastUngrouped = (root: object): { id: string; at: number; } | undefined => lastUngrouped.get(root);
74
+ export const setLastUngrouped = (root: object, v: { id: string; at: number; }): void => { lastUngrouped.set(root, v); };
75
+ export const clearLastUngrouped = (root: object): void => { lastUngrouped.delete(root); };
@@ -0,0 +1,69 @@
1
+ import { getListenerBucket, getNode } from './listener-trie.ts';
2
+ import type { ChangeListener, PathTrieNode } from './types.ts';
3
+
4
+
5
+ /**
6
+ * Compute the set of listeners affected by a change at the given path.
7
+ *
8
+ * Collects listeners in this order:
9
+ * - Global listeners (onAny)
10
+ * - Down listeners on the path and all ancestors (listen for descendants)
11
+ * - Exact listeners at the exact path
12
+ * - Up listeners on all descendants (listen for ancestors)
13
+ *
14
+ * @param root - The root object
15
+ * @param path - The path where the change occurred
16
+ * @returns Set of all affected listeners
17
+ */
18
+ export const computeAffectedListeners = (root: object, path: string[]): Set<ChangeListener> => {
19
+ const bucket = getListenerBucket(root);
20
+ const affected: Set<ChangeListener> = new Set();
21
+
22
+ if (!bucket)
23
+ return affected;
24
+
25
+ // Global listeners
26
+ bucket.global.forEach(l => affected.add(l));
27
+
28
+ const rootNode = bucket.trie;
29
+
30
+ // Down listeners on ancestors (including root)
31
+ {
32
+ let node: PathTrieNode | undefined = rootNode;
33
+ if (node && node.modes.size > 0)
34
+ node.modes.get('down')?.forEach(l => affected.add(l));
35
+
36
+ for (const seg of path) {
37
+ node = node?.children.get(seg);
38
+ if (!node)
39
+ break;
40
+
41
+ node.modes.get('down')?.forEach(l => affected.add(l));
42
+ }
43
+ }
44
+
45
+ // Exact listeners at the path
46
+ {
47
+ const node = getNode(rootNode, path);
48
+ if (node)
49
+ node.modes.get('exact')?.forEach(l => affected.add(l));
50
+ }
51
+
52
+ // Up listeners on descendants (strictly deeper than path)
53
+ {
54
+ const start = getNode(rootNode, path);
55
+ if (start) {
56
+ for (const child of start.children.values()) {
57
+ const stack: PathTrieNode[] = [ child ];
58
+ while (stack.length) {
59
+ const n = stack.pop()!;
60
+ n.modes.get('up')?.forEach(l => affected.add(l));
61
+ for (const c of n.children.values())
62
+ stack.push(c);
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ return affected;
69
+ };
@@ -0,0 +1,103 @@
1
+ import type { ChangeListener, ListenerBucket, PathMode, PathTrieNode } from './types.ts';
2
+
3
+ // Per-root listener registry (global + trie)
4
+ const listenerCache: WeakMap<object, ListenerBucket> = new WeakMap();
5
+
6
+ export const getListenerBucket = (root: object): ListenerBucket | undefined => listenerCache.get(root);
7
+
8
+ export const ensureListenerBucket = (root: object): ListenerBucket => {
9
+ let bucket = listenerCache.get(root);
10
+
11
+ if (!bucket) {
12
+ bucket = {
13
+ global: new Set<ChangeListener>(),
14
+ trie: { children: new Map<string, PathTrieNode>(), modes: new Map<PathMode, Set<ChangeListener>>() },
15
+ };
16
+ listenerCache.set(root, bucket);
17
+ }
18
+
19
+ return bucket;
20
+ };
21
+
22
+ const isNodeEmpty = (node: PathTrieNode): boolean => node.children.size === 0 && (node.modes.size === 0);
23
+
24
+ export const cleanupListenerBucket = (root: object, bucket: ListenerBucket): void => {
25
+ if (bucket.global.size === 0 && isNodeEmpty(bucket.trie))
26
+ listenerCache.delete(root);
27
+ };
28
+
29
+ export const getOrCreateNode = (root: PathTrieNode, segs: string[]): PathTrieNode => {
30
+ let node = root;
31
+ for (const s of segs) {
32
+ let next = node.children.get(s);
33
+ if (!next) {
34
+ next = { children: new Map<string, PathTrieNode>(), modes: new Map<PathMode, Set<ChangeListener>>() };
35
+ node.children.set(s, next);
36
+ }
37
+
38
+ node = next;
39
+ }
40
+
41
+ return node;
42
+ };
43
+
44
+ export const getNode = (root: PathTrieNode, segs: string[]): PathTrieNode | undefined => {
45
+ let node: PathTrieNode | undefined = root;
46
+ for (const s of segs) {
47
+ node = node?.children.get(s);
48
+ if (!node)
49
+ return undefined;
50
+ }
51
+
52
+ return node;
53
+ };
54
+
55
+ export const prunePathIfEmpty = (root: PathTrieNode, segs: string[]): void => {
56
+ const stack: { seg: string; node: PathTrieNode; }[] = [];
57
+ let node: PathTrieNode | undefined = root;
58
+ for (const s of segs) {
59
+ if (!node)
60
+ return;
61
+
62
+ stack.push({ seg: s, node });
63
+ node = node.children.get(s);
64
+ }
65
+ // node is the target node
66
+ if (!node)
67
+ return;
68
+
69
+ // Walk back up pruning empty nodes
70
+ for (let i = segs.length - 1; i >= 0; i--) {
71
+ const parent = stack[i]!.node;
72
+ const seg = stack[i]!.seg;
73
+ const child = parent.children.get(seg)!;
74
+ if (child.children.size === 0 && child.modes.size === 0)
75
+ parent.children.delete(seg);
76
+ else
77
+ break;
78
+ }
79
+ };
80
+
81
+ export const addListenerToTrie = (root: PathTrieNode, segs: string[], mode: PathMode, listener: ChangeListener): PathTrieNode => {
82
+ const node = getOrCreateNode(root, segs);
83
+ const set = node.modes.get(mode) ?? new Set<ChangeListener>();
84
+ set.add(listener);
85
+ node.modes.set(mode, set);
86
+
87
+ return node;
88
+ };
89
+
90
+ export const removeListenerFromTrie = (root: PathTrieNode, segs: string[], mode: PathMode, listener: ChangeListener): void => {
91
+ const node = getNode(root, segs);
92
+ if (!node)
93
+ return;
94
+
95
+ const set = node.modes.get(mode);
96
+ if (set) {
97
+ set.delete(listener);
98
+ if (set.size === 0)
99
+ node.modes.delete(mode);
100
+ }
101
+
102
+ prunePathIfEmpty(root, segs);
103
+ };