jsonbadger 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +36 -18
  2. package/docs/api/connection.md +144 -0
  3. package/docs/api/delta-tracker.md +106 -0
  4. package/docs/api/document.md +77 -0
  5. package/docs/api/field-types.md +329 -0
  6. package/docs/api/index.md +35 -0
  7. package/docs/api/model.md +392 -0
  8. package/docs/api/query-builder.md +81 -0
  9. package/docs/api/schema.md +204 -0
  10. package/docs/architecture-flow.md +397 -0
  11. package/docs/examples.md +495 -218
  12. package/docs/jsonb-ops.md +171 -0
  13. package/docs/lifecycle/model-compilation.md +111 -0
  14. package/docs/lifecycle.md +146 -0
  15. package/docs/query-translation.md +11 -10
  16. package/package.json +10 -3
  17. package/src/connection/connect.js +12 -17
  18. package/src/connection/connection.js +128 -0
  19. package/src/connection/server-capabilities.js +60 -59
  20. package/src/constants/defaults.js +32 -19
  21. package/src/constants/{id-strategies.js → id-strategy.js} +28 -29
  22. package/src/constants/intake-mode.js +8 -0
  23. package/src/debug/debug-logger.js +17 -15
  24. package/src/errors/model-overwrite-error.js +25 -0
  25. package/src/errors/query-error.js +25 -23
  26. package/src/errors/validation-error.js +25 -23
  27. package/src/field-types/base-field-type.js +137 -140
  28. package/src/field-types/builtins/advanced.js +365 -365
  29. package/src/field-types/builtins/index.js +579 -585
  30. package/src/field-types/field-type-namespace.js +9 -0
  31. package/src/field-types/registry.js +149 -122
  32. package/src/index.js +26 -36
  33. package/src/migration/ensure-index.js +157 -154
  34. package/src/migration/ensure-schema.js +27 -15
  35. package/src/migration/ensure-table.js +44 -31
  36. package/src/migration/schema-indexes-resolver.js +8 -6
  37. package/src/model/document-instance.js +29 -540
  38. package/src/model/document.js +60 -0
  39. package/src/model/factory/constants.js +36 -0
  40. package/src/model/factory/index.js +58 -0
  41. package/src/model/model.js +875 -0
  42. package/src/model/operations/delete-one.js +39 -0
  43. package/src/model/operations/insert-one.js +35 -0
  44. package/src/model/operations/query-builder.js +132 -0
  45. package/src/model/operations/update-one.js +333 -0
  46. package/src/model/state.js +34 -0
  47. package/src/schema/field-definition-parser.js +213 -218
  48. package/src/schema/path-introspection.js +87 -82
  49. package/src/schema/schema-compiler.js +126 -212
  50. package/src/schema/schema.js +621 -138
  51. package/src/sql/index.js +17 -0
  52. package/src/sql/jsonb/ops.js +153 -0
  53. package/src/{query → sql/jsonb}/path-parser.js +54 -43
  54. package/src/sql/jsonb/read/elem-match.js +133 -0
  55. package/src/{query → sql/jsonb/read}/operators/contains.js +13 -7
  56. package/src/sql/jsonb/read/operators/elem-match.js +9 -0
  57. package/src/{query → sql/jsonb/read}/operators/has-all-keys.js +17 -11
  58. package/src/{query → sql/jsonb/read}/operators/has-any-keys.js +18 -11
  59. package/src/sql/jsonb/read/operators/has-key.js +12 -0
  60. package/src/{query → sql/jsonb/read}/operators/jsonpath-exists.js +22 -15
  61. package/src/{query → sql/jsonb/read}/operators/jsonpath-match.js +22 -15
  62. package/src/{query → sql/jsonb/read}/operators/size.js +23 -16
  63. package/src/sql/parameter-binder.js +18 -13
  64. package/src/sql/read/build-count-query.js +12 -0
  65. package/src/sql/read/build-find-query.js +25 -0
  66. package/src/sql/read/limit-skip.js +21 -0
  67. package/src/sql/read/sort.js +85 -0
  68. package/src/sql/read/where/base-fields.js +310 -0
  69. package/src/sql/read/where/casting.js +90 -0
  70. package/src/sql/read/where/context.js +79 -0
  71. package/src/sql/read/where/field-clause.js +58 -0
  72. package/src/sql/read/where/index.js +38 -0
  73. package/src/sql/read/where/operator-entries.js +29 -0
  74. package/src/{query → sql/read/where}/operators/all.js +16 -10
  75. package/src/sql/read/where/operators/eq.js +12 -0
  76. package/src/{query → sql/read/where}/operators/gt.js +23 -16
  77. package/src/{query → sql/read/where}/operators/gte.js +23 -16
  78. package/src/{query → sql/read/where}/operators/in.js +18 -12
  79. package/src/sql/read/where/operators/index.js +40 -0
  80. package/src/{query → sql/read/where}/operators/lt.js +23 -16
  81. package/src/{query → sql/read/where}/operators/lte.js +23 -16
  82. package/src/sql/read/where/operators/ne.js +12 -0
  83. package/src/{query → sql/read/where}/operators/nin.js +18 -12
  84. package/src/{query → sql/read/where}/operators/regex.js +14 -8
  85. package/src/sql/read/where/operators.js +126 -0
  86. package/src/sql/read/where/text-operators.js +83 -0
  87. package/src/sql/run.js +46 -0
  88. package/src/sql/write/build-delete-query.js +33 -0
  89. package/src/sql/write/build-insert-query.js +42 -0
  90. package/src/sql/write/build-update-query.js +65 -0
  91. package/src/utils/assert.js +34 -27
  92. package/src/utils/delta-tracker/.archive/1 tracker-redesign-codex-v2.md +250 -0
  93. package/src/utils/delta-tracker/.archive/1 tracker-redesign-gemini.md +101 -0
  94. package/src/utils/delta-tracker/.archive/2 evaluation by gemini.txt +65 -0
  95. package/src/utils/delta-tracker/.archive/2 evaluation by grok.txt +39 -0
  96. package/src/utils/delta-tracker/.archive/3 gemini evaluate grok.txt +37 -0
  97. package/src/utils/delta-tracker/.archive/3 grok evaluate gemini.txt +63 -0
  98. package/src/utils/delta-tracker/.archive/4 gemini veredict.txt +16 -0
  99. package/src/utils/delta-tracker/.archive/index.1.js +587 -0
  100. package/src/utils/delta-tracker/.archive/index.2.js +612 -0
  101. package/src/utils/delta-tracker/index.js +592 -0
  102. package/src/utils/dirty-tracker/inline.js +335 -0
  103. package/src/utils/dirty-tracker/instance.js +414 -0
  104. package/src/utils/dirty-tracker/static.js +343 -0
  105. package/src/utils/json-safe.js +13 -9
  106. package/src/utils/object-path.js +227 -33
  107. package/src/utils/object.js +408 -168
  108. package/src/utils/string.js +55 -0
  109. package/src/utils/value.js +169 -30
  110. package/docs/api.md +0 -152
  111. package/src/connection/disconnect.js +0 -16
  112. package/src/connection/pool-store.js +0 -46
  113. package/src/model/model-factory.js +0 -555
  114. package/src/query/limit-skip-compiler.js +0 -31
  115. package/src/query/operators/elem-match.js +0 -3
  116. package/src/query/operators/eq.js +0 -6
  117. package/src/query/operators/has-key.js +0 -6
  118. package/src/query/operators/index.js +0 -60
  119. package/src/query/operators/ne.js +0 -6
  120. package/src/query/query-builder.js +0 -93
  121. package/src/query/sort-compiler.js +0 -30
  122. package/src/query/where-compiler.js +0 -477
  123. package/src/sql/sql-runner.js +0 -31
@@ -0,0 +1,612 @@
1
+ import {to_array} from '#src/utils/array.js';
2
+ import {deep_clone, is_function, is_not_object, is_object} from '#src/utils/value.js';
3
+
4
+ // DELTA-TRACKER v1
5
+
6
+ /**
7
+ * Create a new state store instance that proxies a root object,
8
+ * optionally restricting tracking to specific child branches.
9
+ * Emits a structured NoSQL-style delta object natively.
10
+ *
11
+ * @param {object} target
12
+ * @param {object} [options]
13
+ * @param {string[]} [options.track] - Array of top-level keys to track (e.g., ['data', 'fields'])
14
+ * @param {object} [options.watch]
15
+ * @param {function} [options.intercept_set]
16
+ * @returns {Proxy}
17
+ */
18
+ function DeltaTracker(target, options = {}) {
19
+ const track_list = get_track_list(options);
20
+ const base_state = clone_tracked_state(target, track_list);
21
+
22
+ const store = {
23
+ base_state,
24
+ // State-collapsed delta tracking
25
+ delta_set: new Map(), // path -> next_value
26
+ delta_unset: new Set(), // paths that were deleted
27
+ replace_roots: new Map(), // root_key -> full_object (when tracked roots are replaced entirely)
28
+ watchers: [],
29
+ // Reuse nested proxies so repeated reads preserve referential equality.
30
+ proxy_cache: new WeakMap()
31
+ };
32
+
33
+ let root_proxy;
34
+ const get_root = () => root_proxy;
35
+
36
+ // Build the intercepting proxy
37
+ root_proxy = build_proxy(target, target, store, get_root, options);
38
+ // Seed the cache with the root object so nested traversals can share proxy identity.
39
+ store.proxy_cache.set(target, root_proxy);
40
+
41
+ // Parse and bind Watchers
42
+ if(is_object(options.watch)) {
43
+ const watch_keys = Object.keys(options.watch);
44
+ let key_index = 0;
45
+
46
+ while(key_index < watch_keys.length) {
47
+ const watch_path = watch_keys[key_index];
48
+ add_watcher(store, target, root_proxy, watch_path, options.watch[watch_path]);
49
+ key_index += 1;
50
+ }
51
+ }
52
+
53
+ return root_proxy;
54
+ }
55
+
56
+ /*
57
+ * LAZY PROXY BUILDER
58
+ */
59
+
60
+ /**
61
+ * Build one lazy proxy for the provided object branch.
62
+ *
63
+ * @param {object} target_object
64
+ * @param {object} root_object
65
+ * @param {object} store
66
+ * @param {function(): Proxy} get_root
67
+ * @param {object} [options]
68
+ * @returns {Proxy}
69
+ */
70
+ function build_proxy(target_object, root_object, store, get_root, options = {}) {
71
+ const track_list = get_track_list(options);
72
+ const base_path = options.base_path || '';
73
+ let intercept_set = (path, next_value) => next_value;
74
+
75
+ if(is_function(options.intercept_set)) {
76
+ intercept_set = options.intercept_set;
77
+ }
78
+
79
+ const internal_methods = Object.assign(Object.create(null), {
80
+ $has_changes: () => {
81
+ return has_changes(store);
82
+ },
83
+ $get_delta: () => {
84
+ return get_delta(store);
85
+ },
86
+ $reset_changes: () => {
87
+ return reset_changes(store, get_root());
88
+ },
89
+ $rebase_changes: () => {
90
+ return rebase_changes(store, root_object, options);
91
+ },
92
+ $watch: (path, watch_options) => {
93
+ return add_watcher(store, root_object, get_root(), path, watch_options);
94
+ }
95
+ });
96
+
97
+ return new Proxy(target_object, {
98
+ get(target, prop) {
99
+ // Do not proxy internal JS symbols
100
+ if(typeof prop === 'symbol') {
101
+ return target[prop];
102
+ }
103
+
104
+ // Root level: allow access to tracker methods and pass through untracked branches
105
+ if(base_path === '') {
106
+ // Keep helper methods only at the root so tracked branches stay free of tracker
107
+ // method names and can be treated as normal application data.
108
+ const internal_method = internal_methods[prop];
109
+
110
+ if(internal_method) {
111
+ return internal_method;
112
+ }
113
+
114
+ if(prop in target && is_function(target[prop])) {
115
+ return target[prop];
116
+ }
117
+
118
+ // Keep untracked root branches raw so only the declared tracked surface participates
119
+ // in delta bookkeeping and helper-method interception.
120
+ if(track_list && !track_list.includes(prop)) {
121
+ return target[prop];
122
+ }
123
+ }
124
+
125
+ const value = target[prop];
126
+
127
+ // Lazy-proxy nested objects on access
128
+ if(is_object(value)) {
129
+ const cached_proxy = store.proxy_cache.get(value);
130
+
131
+ if(cached_proxy) {
132
+ // Reuse the same proxy instance so `tracker.user === tracker.user` stays true.
133
+ return cached_proxy;
134
+ }
135
+
136
+ const next_path = base_path === '' ? prop : `${base_path}.${prop}`;
137
+ const next_options = {...options, base_path: next_path};
138
+ const nested_proxy = build_proxy(value, root_object, store, get_root, next_options);
139
+
140
+ store.proxy_cache.set(value, nested_proxy);
141
+
142
+ return nested_proxy;
143
+ }
144
+
145
+ return value;
146
+ },
147
+
148
+ set(target, prop, value) {
149
+ if(typeof prop === 'symbol') {
150
+ target[prop] = value;
151
+ return true;
152
+ }
153
+
154
+ // Do not track mutations to functions/methods
155
+ if(base_path === '' && is_function(target[prop])) {
156
+ target[prop] = value;
157
+ return true;
158
+ }
159
+
160
+ // Bypass tracking for untracked top-level keys
161
+ if(base_path === '' && track_list && !track_list.includes(prop)) {
162
+ target[prop] = value;
163
+ return true;
164
+ }
165
+
166
+ const full_path = base_path === '' ? prop : `${base_path}.${prop}`;
167
+ const next_value = intercept_set(full_path, value);
168
+ const old_value = target[prop];
169
+
170
+ // Treat explicit undefined assignment identically to deletion
171
+ if(next_value === undefined) {
172
+ const deleted = Reflect.deleteProperty(target, prop);
173
+
174
+ if(deleted) {
175
+ // Are we deleting a full tracked root? (e.g. `delete document.data`)
176
+ if(base_path === '' && track_list && track_list.includes(prop)) {
177
+ store.replace_roots.delete(prop);
178
+ store.delta_unset.add(full_path);
179
+ clear_nested_deltas(store, full_path);
180
+
181
+ } else {
182
+ // Cross-cancellation: deleting a key nullifies any pending sets for it
183
+ store.delta_unset.add(full_path);
184
+ store.delta_set.delete(full_path);
185
+ clear_nested_deltas(store, full_path);
186
+ }
187
+
188
+ // Trigger Watchers (old_value will be the removed object, new value will be undefined)
189
+ check_watchers(store, full_path, old_value, root_object, get_root());
190
+ }
191
+
192
+ return true;
193
+ }
194
+
195
+ // Delta state is snapshot-based where possible to naturally eliminate redundant writes
196
+ const original_value = read_path(store.base_state, full_path);
197
+
198
+ // Apply the mutation
199
+ target[prop] = next_value;
200
+
201
+ // Is this an assignment replacing a tracked root completely? (e.g. `document.data = {...}`)
202
+ if(base_path === '' && track_list && track_list.includes(prop)) {
203
+ store.replace_roots.set(prop, next_value);
204
+ clear_nested_deltas(store, full_path);
205
+
206
+ } else {
207
+ // Standard nested operation tracking
208
+ if(original_value === next_value) {
209
+ // Reverted to baseline - remove from delta sets entirely
210
+ store.delta_set.delete(full_path);
211
+ store.delta_unset.delete(full_path);
212
+ } else {
213
+ // Cross-cancellation: setting a key overrides any unsets for it
214
+ store.delta_set.set(full_path, next_value);
215
+ store.delta_unset.delete(full_path);
216
+ clear_nested_deltas(store, full_path); // Overwriting parent nullifies child ops
217
+ }
218
+ }
219
+
220
+ // Trigger Watchers
221
+ if(old_value !== next_value) {
222
+ check_watchers(store, full_path, old_value, root_object, get_root());
223
+ }
224
+
225
+ return true;
226
+ },
227
+
228
+ deleteProperty(target, prop) {
229
+ if(typeof prop === 'symbol') {
230
+ return Reflect.deleteProperty(target, prop);
231
+ }
232
+
233
+ // Bypass tracking for untracked top-level keys
234
+ if(base_path === '' && track_list && !track_list.includes(prop)) {
235
+ return Reflect.deleteProperty(target, prop);
236
+ }
237
+
238
+ const full_path = base_path === '' ? prop : `${base_path}.${prop}`;
239
+ const old_value = target[prop];
240
+
241
+ // Apply the mutation
242
+ const deleted = Reflect.deleteProperty(target, prop);
243
+
244
+ if(deleted) {
245
+ // Are we deleting a full tracked root? (e.g. `delete document.data`)
246
+ if(base_path === '' && track_list && track_list.includes(prop)) {
247
+ store.replace_roots.delete(prop);
248
+ store.delta_unset.add(full_path);
249
+ clear_nested_deltas(store, full_path);
250
+
251
+ } else {
252
+ // Cross-cancellation: deleting a key nullifies any pending sets for it
253
+ store.delta_unset.add(full_path);
254
+ store.delta_set.delete(full_path);
255
+ clear_nested_deltas(store, full_path);
256
+ }
257
+
258
+ // Trigger Watchers (old_value will be the removed object, new value will be undefined)
259
+ check_watchers(store, full_path, old_value, root_object, get_root());
260
+ }
261
+
262
+ return deleted;
263
+ }
264
+ });
265
+ }
266
+
267
+ /*
268
+ * STATE MANAGEMENT HELPERS
269
+ */
270
+
271
+ /**
272
+ * Wipes out any pending set/unset instructions for children of a path.
273
+ * Used when a parent object is overwritten or deleted natively collapsing state.
274
+ *
275
+ * @param {object} store
276
+ * @param {string} parent_path
277
+ * @returns {void}
278
+ */
279
+ function clear_nested_deltas(store, parent_path) {
280
+ const prefix = `${parent_path}.`;
281
+
282
+ for(const key of store.delta_set.keys()) {
283
+ if(key.startsWith(prefix)) {
284
+ store.delta_set.delete(key);
285
+ }
286
+ }
287
+
288
+ for(const key of store.delta_unset) {
289
+ if(key.startsWith(prefix)) {
290
+ store.delta_unset.delete(key);
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Normalize tracker options into a stable tracked-root list.
297
+ *
298
+ * @param {object} options
299
+ * @param {string|string[]} [options.track]
300
+ * @returns {string[]|null}
301
+ */
302
+ function get_track_list(options) {
303
+ return options.track === undefined ? null : to_array(options.track);
304
+ }
305
+
306
+ /**
307
+ * Clone either the full source object or only the tracked root keys.
308
+ *
309
+ * @param {object} source_object
310
+ * @param {string[]|null} track_list
311
+ * @returns {object}
312
+ */
313
+ function clone_tracked_state(source_object, track_list) {
314
+ if(!track_list) {
315
+ // Full tracking can reuse the deep clone directly instead of copying into another object.
316
+ return deep_clone(source_object);
317
+ }
318
+
319
+ const cloned_state = {};
320
+ let key_index = 0;
321
+
322
+ while(key_index < track_list.length) {
323
+ const key = track_list[key_index];
324
+
325
+ if(key in source_object) {
326
+ cloned_state[key] = deep_clone(source_object[key]);
327
+ }
328
+
329
+ key_index += 1;
330
+ }
331
+
332
+ return cloned_state;
333
+ }
334
+
335
+ /**
336
+ * Check whether the tracker currently holds any pending changes.
337
+ *
338
+ * @param {object} store
339
+ * @returns {boolean}
340
+ */
341
+ function has_changes(store) {
342
+ if(!store) {
343
+ return false;
344
+ }
345
+
346
+ return store.delta_set.size > 0 || store.delta_unset.size > 0 || store.replace_roots.size > 0;
347
+ }
348
+
349
+ /**
350
+ * Return the current delta snapshot for the tracked roots.
351
+ *
352
+ * @param {object} store
353
+ * @returns {{replace_roots: object, set: object, unset: string[]}}
354
+ */
355
+ function get_delta(store) {
356
+ if(!store) {
357
+ return {replace_roots: {}, set: {}, unset: []};
358
+ }
359
+
360
+ return {
361
+ replace_roots: Object.fromEntries(store.replace_roots),
362
+ set: Object.fromEntries(store.delta_set),
363
+ unset: Array.from(store.delta_unset)
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Reset tracked roots back to the current rebased snapshot.
369
+ *
370
+ * @param {object} store
371
+ * @param {Proxy} proxy
372
+ * @returns {void}
373
+ */
374
+ function reset_changes(store, proxy) {
375
+ const original = store.base_state;
376
+ const original_keys = Object.keys(original);
377
+ let key_index = 0;
378
+
379
+ // Resetting through the proxy triggers setters naturally, effectively
380
+ // reverting state and wiping out pending deltas through the snapshot diff.
381
+ while(key_index < original_keys.length) {
382
+ const key = original_keys[key_index];
383
+
384
+ if(!is_function(original[key])) {
385
+ proxy[key] = deep_clone(original[key]);
386
+ }
387
+
388
+ key_index += 1;
389
+ }
390
+
391
+ // Remove any fully replaced roots that didn't exist in the baseline
392
+ for(const key of store.replace_roots.keys()) {
393
+ if(!(key in original)) {
394
+ delete proxy[key];
395
+ }
396
+ }
397
+
398
+ store.delta_set.clear();
399
+ store.delta_unset.clear();
400
+ store.replace_roots.clear();
401
+ }
402
+
403
+ /**
404
+ * Rebase the tracker snapshot against the current root object state.
405
+ *
406
+ * @param {object} store
407
+ * @param {object} root_object
408
+ * @param {object} options
409
+ * @returns {void}
410
+ */
411
+ function rebase_changes(store, root_object, options) {
412
+ const track_list = get_track_list(options);
413
+ store.base_state = clone_tracked_state(root_object, track_list);
414
+
415
+ store.delta_set.clear();
416
+ store.delta_unset.clear();
417
+ store.replace_roots.clear();
418
+ }
419
+
420
+ /*
421
+ * WATCHER HELPERS
422
+ */
423
+
424
+ /**
425
+ * Register one watcher against the tracked root proxy.
426
+ *
427
+ * @param {object} store
428
+ * @param {object} root_object
429
+ * @param {Proxy} proxy
430
+ * @param {string} path
431
+ * @param {object|function} options
432
+ * @returns {function}
433
+ */
434
+ function add_watcher(store, root_object, proxy, path, options) {
435
+ const handler = is_function(options) ? options : () => {};
436
+ const config = is_object(options) ? options : {handler};
437
+
438
+ const watcher = {
439
+ path,
440
+ handler: config.handler,
441
+ deep: config.deep === true,
442
+ once: config.once === true,
443
+ active: true
444
+ };
445
+
446
+ store.watchers.push(watcher);
447
+
448
+ if(config.immediate) {
449
+ const initial_value = read_path(root_object, path);
450
+ watcher.handler.call(proxy, initial_value, undefined);
451
+
452
+ if(watcher.once) {
453
+ watcher.active = false;
454
+ }
455
+ }
456
+
457
+ // Return the closure to unwatch
458
+ return () => {
459
+ watcher.active = false;
460
+ const index = store.watchers.indexOf(watcher);
461
+
462
+ if(index !== -1) {
463
+ store.watchers.splice(index, 1);
464
+ }
465
+ };
466
+ }
467
+
468
+ const pending_watchers = new Map();
469
+ let is_flushing = false;
470
+
471
+ /**
472
+ * Evaluate which watchers should react to one mutated path.
473
+ *
474
+ * @param {object} store
475
+ * @param {string} mutated_path
476
+ * @param {*} old_value
477
+ * @param {object} root_object
478
+ * @param {Proxy} root_proxy
479
+ * @returns {void}
480
+ */
481
+ function check_watchers(store, mutated_path, old_value, root_object, root_proxy) {
482
+ const watchers = store.watchers;
483
+ let watcher_index = 0;
484
+
485
+ while(watcher_index < watchers.length) {
486
+ const watcher = watchers[watcher_index];
487
+ if(!watcher.active) {
488
+ watcher_index += 1;
489
+ continue;
490
+ }
491
+
492
+ let should_trigger = false;
493
+ let handler_new_value = undefined;
494
+ let handler_old_value = old_value;
495
+
496
+ // Exact path match
497
+ if(watcher.path === mutated_path) {
498
+ should_trigger = true;
499
+ handler_new_value = read_path(root_object, mutated_path);
500
+ }
501
+ // Deep mutation (e.g., watching 'user', mutated 'user.name')
502
+ else if(watcher.deep && mutated_path.startsWith(watcher.path + '.')) {
503
+ should_trigger = true;
504
+ // Deep child writes mutate the same parent object in place, so there is no separate
505
+ // pre-mutation parent snapshot to pass through here.
506
+ handler_new_value = read_path(root_object, watcher.path);
507
+ handler_old_value = handler_new_value;
508
+ }
509
+ // Parent replacement (e.g., watching 'user.name', mutated 'user')
510
+ else if(watcher.path.startsWith(mutated_path + '.')) {
511
+ should_trigger = true;
512
+ handler_new_value = read_path(root_object, watcher.path);
513
+
514
+ // Attempt to extract the old nested value from the replaced parent object
515
+ const nested_path = watcher.path.substring(mutated_path.length + 1);
516
+ handler_old_value = read_path(old_value, nested_path);
517
+ }
518
+
519
+ if(should_trigger) {
520
+ queue_watcher(watcher, handler_new_value, handler_old_value, root_proxy);
521
+ }
522
+
523
+ watcher_index += 1;
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Queue one watcher callback into the current microtask batch.
529
+ *
530
+ * @param {object} watcher
531
+ * @param {*} new_value
532
+ * @param {*} old_value
533
+ * @param {*} context
534
+ * @returns {void}
535
+ */
536
+ function queue_watcher(watcher, new_value, old_value, context) {
537
+ // Deduplicate watchers in the same tick.
538
+ // If it exists, we update to the latest new_value, but keep the initial old_value of this tick.
539
+ if(pending_watchers.has(watcher)) {
540
+ pending_watchers.get(watcher).new_value = new_value;
541
+ } else {
542
+ pending_watchers.set(watcher, {new_value, old_value, context});
543
+ }
544
+
545
+ if(is_flushing) {
546
+ return;
547
+ }
548
+
549
+ is_flushing = true;
550
+
551
+ // Batch watcher delivery into one microtask so multiple synchronous writes collapse into a
552
+ // single callback pass with the latest value and the first old value from that tick.
553
+ Promise.resolve().then(() => {
554
+ const jobs = Array.from(pending_watchers.entries());
555
+ pending_watchers.clear();
556
+ is_flushing = false;
557
+
558
+ let job_index = 0;
559
+ while(job_index < jobs.length) {
560
+ const job_watcher = jobs[job_index][0];
561
+ const job_args = jobs[job_index][1];
562
+
563
+ if(job_watcher.active) {
564
+ job_watcher.handler.call(
565
+ job_args.context,
566
+ job_args.new_value,
567
+ job_args.old_value
568
+ );
569
+
570
+ if(job_watcher.once) {
571
+ job_watcher.active = false; // Mark inactive after first run
572
+ }
573
+ }
574
+
575
+ job_index += 1;
576
+ }
577
+ });
578
+ }
579
+
580
+ /*
581
+ * PATH HELPERS
582
+ */
583
+
584
+ /**
585
+ * Read one nested value by dot-notation path.
586
+ *
587
+ * @param {object} root_object
588
+ * @param {string} dot_path
589
+ * @returns {*}
590
+ */
591
+ function read_path(root_object, dot_path) {
592
+ if(!dot_path) {
593
+ return root_object;
594
+ }
595
+
596
+ const segments = dot_path.split('.');
597
+ let current_value = root_object;
598
+ let segment_index = 0;
599
+
600
+ while(segment_index < segments.length) {
601
+ if(is_not_object(current_value)) {
602
+ return undefined;
603
+ }
604
+
605
+ current_value = current_value[segments[segment_index]];
606
+ segment_index += 1;
607
+ }
608
+
609
+ return current_value;
610
+ }
611
+
612
+ export default DeltaTracker;