@montra-interactive/deepstate 0.1.0

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.
@@ -0,0 +1,881 @@
1
+ /**
2
+ * deepstate v2 - Nested BehaviorSubjects Architecture
3
+ *
4
+ * Each property has its own observable that emits standalone.
5
+ * Parent notifications flow upward automatically via RxJS subscriptions.
6
+ * Siblings are never notified.
7
+ *
8
+ * Architecture:
9
+ * - Leaves (primitives): BehaviorSubject is source of truth
10
+ * - Objects: combineLatest(children) derives the observable, children are source of truth
11
+ * - Arrays: BehaviorSubject<T[]> is source of truth, children are projections
12
+ */
13
+ import { BehaviorSubject, combineLatest, of } from "rxjs";
14
+ import { map, distinctUntilChanged, shareReplay, take, filter, } from "rxjs/operators";
15
+ // =============================================================================
16
+ // Counters for performance comparison
17
+ // =============================================================================
18
+ export let distinctCallCount = 0;
19
+ export function resetDistinctCallCount() {
20
+ distinctCallCount = 0;
21
+ }
22
+ // Wrap distinctUntilChanged to count calls
23
+ function countedDistinctUntilChanged(compareFn) {
24
+ return distinctUntilChanged((a, b) => {
25
+ distinctCallCount++;
26
+ if (compareFn)
27
+ return compareFn(a, b);
28
+ return a === b;
29
+ });
30
+ }
31
+ // =============================================================================
32
+ // Deep Freeze
33
+ // =============================================================================
34
+ function deepFreeze(obj) {
35
+ if (obj === null || typeof obj !== "object")
36
+ return obj;
37
+ if (Object.isFrozen(obj))
38
+ return obj;
39
+ Object.freeze(obj);
40
+ if (Array.isArray(obj)) {
41
+ obj.forEach((item) => deepFreeze(item));
42
+ }
43
+ else {
44
+ Object.keys(obj).forEach((key) => {
45
+ deepFreeze(obj[key]);
46
+ });
47
+ }
48
+ return obj;
49
+ }
50
+ // Symbols for internal access
51
+ const NODE = Symbol("node");
52
+ // =============================================================================
53
+ // Node Creation
54
+ // =============================================================================
55
+ function createLeafNode(value) {
56
+ const subject$ = new BehaviorSubject(value);
57
+ // Use distinctUntilChanged to prevent duplicate emissions for same value
58
+ const distinct$ = subject$.pipe(distinctUntilChanged(), shareReplay(1));
59
+ // Keep hot
60
+ distinct$.subscribe();
61
+ return {
62
+ $: distinct$,
63
+ get: () => subject$.getValue(),
64
+ set: (v) => subject$.next(v),
65
+ subscribeOnce: (callback) => {
66
+ return distinct$.pipe(take(1)).subscribe(callback);
67
+ },
68
+ };
69
+ }
70
+ function createObjectNode(value) {
71
+ const keys = Object.keys(value);
72
+ const children = new Map();
73
+ // Create child nodes for each property
74
+ // Pass maybeNullable: true so null values get NullableNodeCore
75
+ // which can be upgraded to objects later
76
+ for (const key of keys) {
77
+ children.set(key, createNodeForValue(value[key], true));
78
+ }
79
+ // Helper to get current value from children
80
+ const getCurrentValue = () => {
81
+ const result = {};
82
+ for (const [key, child] of children) {
83
+ result[key] = child.get();
84
+ }
85
+ return result;
86
+ };
87
+ // Handle empty objects
88
+ if (keys.length === 0) {
89
+ const empty$ = of(value).pipe(shareReplay(1));
90
+ return {
91
+ $: empty$,
92
+ children: children,
93
+ get: () => ({}),
94
+ set: () => { }, // No-op for empty objects
95
+ lock: () => { }, // No-op for empty objects
96
+ unlock: () => { }, // No-op for empty objects
97
+ };
98
+ }
99
+ // Lock for batching updates - when false, emissions are filtered out
100
+ const lock$ = new BehaviorSubject(true);
101
+ // Derive observable from children + lock using combineLatest
102
+ const childObservables = keys.map((key) => children.get(key).$);
103
+ const $ = combineLatest([...childObservables, lock$]).pipe(
104
+ // Only emit when unlocked (lock is last element)
105
+ filter((values) => values[values.length - 1] === true),
106
+ // Remove lock value from output, reconstruct object
107
+ map((values) => {
108
+ const result = {};
109
+ keys.forEach((key, i) => {
110
+ result[key] = values[i];
111
+ });
112
+ return result;
113
+ }), shareReplay(1));
114
+ // Force subscription to make it hot (so emissions work even before external subscribers)
115
+ $.subscribe();
116
+ // Create a version that freezes on emission
117
+ const frozen$ = $.pipe(map(deepFreeze));
118
+ return {
119
+ $: frozen$,
120
+ children: children,
121
+ get: () => deepFreeze(getCurrentValue()),
122
+ set: (v) => {
123
+ for (const [key, child] of children) {
124
+ child.set(v[key]);
125
+ }
126
+ },
127
+ lock: () => lock$.next(false),
128
+ unlock: () => lock$.next(true),
129
+ // Note: update() is implemented in wrapWithProxy since it needs the proxy reference
130
+ subscribeOnce: (callback) => {
131
+ return frozen$.pipe(take(1)).subscribe(callback);
132
+ },
133
+ };
134
+ }
135
+ function createArrayNode(value) {
136
+ const subject$ = new BehaviorSubject([...value]);
137
+ const childCache = new Map();
138
+ const createChildProjection = (index) => {
139
+ const currentValue = subject$.getValue()[index];
140
+ // If the element is an object, we need nested access
141
+ // Create a "projection node" that reads/writes through the parent array
142
+ if (currentValue !== null && typeof currentValue === "object") {
143
+ return createArrayElementObjectNode(subject$, index, currentValue);
144
+ }
145
+ // Primitive element - simple projection
146
+ const element$ = subject$.pipe(map((arr) => arr[index]), countedDistinctUntilChanged(), shareReplay(1));
147
+ // Force hot
148
+ element$.subscribe();
149
+ return {
150
+ $: element$,
151
+ get: () => subject$.getValue()[index],
152
+ set: (v) => {
153
+ const arr = [...subject$.getValue()];
154
+ arr[index] = v;
155
+ subject$.next(arr);
156
+ },
157
+ subscribeOnce: (callback) => {
158
+ return element$.pipe(take(1)).subscribe(callback);
159
+ },
160
+ };
161
+ };
162
+ // Lock for batching updates - when false, emissions are filtered out
163
+ const lock$ = new BehaviorSubject(true);
164
+ // Create observable that respects lock
165
+ const locked$ = combineLatest([subject$, lock$]).pipe(filter(([_, unlocked]) => unlocked), map(([arr, _]) => arr), map(deepFreeze), shareReplay(1));
166
+ locked$.subscribe(); // Keep hot
167
+ // Length observable (also respects lock)
168
+ const length$ = locked$.pipe(map((arr) => arr.length), distinctUntilChanged(), shareReplay(1));
169
+ length$.subscribe(); // Keep hot
170
+ const lengthWithGet = Object.assign(length$, {
171
+ get: () => subject$.getValue().length,
172
+ });
173
+ return {
174
+ $: locked$,
175
+ childCache,
176
+ get: () => deepFreeze([...subject$.getValue()]),
177
+ set: (v) => {
178
+ // Clear child cache when array is replaced
179
+ childCache.clear();
180
+ subject$.next([...v]);
181
+ },
182
+ subscribeOnce: (callback) => {
183
+ return locked$.pipe(take(1)).subscribe(callback);
184
+ },
185
+ at: (index) => {
186
+ const arr = subject$.getValue();
187
+ if (index < 0 || index >= arr.length)
188
+ return undefined;
189
+ if (!childCache.has(index)) {
190
+ childCache.set(index, createChildProjection(index));
191
+ }
192
+ return childCache.get(index);
193
+ },
194
+ length$: lengthWithGet,
195
+ push: (...items) => {
196
+ const current = subject$.getValue();
197
+ const newArr = [...current, ...items];
198
+ subject$.next(newArr);
199
+ return newArr.length;
200
+ },
201
+ pop: () => {
202
+ const current = subject$.getValue();
203
+ if (current.length === 0)
204
+ return undefined;
205
+ const last = current[current.length - 1];
206
+ // Clear cached node for popped index
207
+ childCache.delete(current.length - 1);
208
+ subject$.next(current.slice(0, -1));
209
+ return deepFreeze(last);
210
+ },
211
+ mapItems: (fn) => {
212
+ return subject$.getValue().map((item, i) => fn(deepFreeze(item), i));
213
+ },
214
+ filterItems: (fn) => {
215
+ return deepFreeze(subject$.getValue().filter((item, i) => fn(deepFreeze(item), i)));
216
+ },
217
+ lock: () => lock$.next(false),
218
+ unlock: () => lock$.next(true),
219
+ // Note: update() is implemented in wrapWithProxy since it needs the proxy reference
220
+ };
221
+ }
222
+ // Symbol to mark nullable nodes
223
+ const NULLABLE_NODE = Symbol("nullableNode");
224
+ /**
225
+ * Creates a node for nullable object types like `{ name: string } | null`
226
+ *
227
+ * When value is null: no children exist, child access returns undefined
228
+ * When value is set to object: children are created lazily from the object's keys
229
+ */
230
+ function createNullableObjectNode(initialValue) {
231
+ // Subject holds the raw value (null or object)
232
+ const subject$ = new BehaviorSubject(initialValue);
233
+ // Children are created lazily when we have an actual object
234
+ let children = null;
235
+ // Pending children - created for deep subscription before parent has a value
236
+ // These are "projection" nodes that derive from the parent observable
237
+ const pendingChildren = new Map();
238
+ // Lock for batching updates
239
+ const lock$ = new BehaviorSubject(true);
240
+ // Build/rebuild children from an object value
241
+ const buildChildren = (obj) => {
242
+ const keys = Object.keys(obj);
243
+ children = new Map();
244
+ for (const key of keys) {
245
+ children.set(key, createNodeForValue(obj[key]));
246
+ }
247
+ };
248
+ // Initialize children if starting with an object
249
+ if (initialValue !== null && initialValue !== undefined && typeof initialValue === "object") {
250
+ buildChildren(initialValue);
251
+ }
252
+ // Helper to get current value
253
+ const getCurrentValue = () => {
254
+ const raw = subject$.getValue();
255
+ if (raw === null || raw === undefined || !children) {
256
+ return raw;
257
+ }
258
+ // Build value from children
259
+ const result = {};
260
+ for (const [key, child] of children) {
261
+ result[key] = child.get();
262
+ }
263
+ return result;
264
+ };
265
+ // Observable that emits the current value, respecting lock
266
+ const $ = combineLatest([subject$, lock$]).pipe(filter(([_, unlocked]) => unlocked), map(([value, _]) => {
267
+ if (value === null || value === undefined || !children) {
268
+ return value;
269
+ }
270
+ // Build from children for consistency
271
+ const result = {};
272
+ for (const [key, child] of children) {
273
+ result[key] = child.get();
274
+ }
275
+ return result;
276
+ }), distinctUntilChanged((a, b) => {
277
+ if (a === null || a === undefined)
278
+ return a === b;
279
+ if (b === null || b === undefined)
280
+ return false;
281
+ return JSON.stringify(a) === JSON.stringify(b);
282
+ }), map(deepFreeze), shareReplay(1));
283
+ $.subscribe(); // Keep hot
284
+ // Create a stable reference object that we can update
285
+ const nodeState = { children };
286
+ // Wrapper to update the children reference
287
+ const updateChildrenRef = () => {
288
+ nodeState.children = children;
289
+ };
290
+ // Override buildChildren to update the reference and connect pending children
291
+ const buildChildrenAndUpdate = (obj) => {
292
+ const keys = Object.keys(obj);
293
+ children = new Map();
294
+ for (const key of keys) {
295
+ // Pass maybeNullable: true so nested nulls also become nullable nodes
296
+ children.set(key, createNodeForValue(obj[key], true));
297
+ }
298
+ updateChildrenRef();
299
+ // Connect pending children to their real counterparts
300
+ for (const [key, pendingNode] of pendingChildren) {
301
+ if (children.has(key) && '_subscribeToRealChild' in pendingNode) {
302
+ pendingNode._subscribeToRealChild();
303
+ }
304
+ }
305
+ };
306
+ // Re-initialize if starting with object (using updated builder)
307
+ if (initialValue !== null && initialValue !== undefined && typeof initialValue === "object") {
308
+ children = null; // Reset
309
+ buildChildrenAndUpdate(initialValue);
310
+ }
311
+ return {
312
+ [NULLABLE_NODE]: true,
313
+ $,
314
+ get children() { return nodeState.children; },
315
+ get: () => deepFreeze(getCurrentValue()),
316
+ set: (value) => {
317
+ if (value === null || value === undefined) {
318
+ // Setting to null - keep children structure for potential reuse but emit null
319
+ subject$.next(value);
320
+ }
321
+ else if (typeof value === "object") {
322
+ // Setting to object
323
+ if (!children) {
324
+ // First time setting an object - create children
325
+ buildChildrenAndUpdate(value);
326
+ }
327
+ else {
328
+ // Update existing children + handle new/removed keys
329
+ const newKeys = new Set(Object.keys(value));
330
+ const existingKeys = new Set(children.keys());
331
+ // Update existing children
332
+ for (const [key, child] of children) {
333
+ if (newKeys.has(key)) {
334
+ child.set(value[key]);
335
+ }
336
+ }
337
+ // Add new keys
338
+ for (const key of newKeys) {
339
+ if (!existingKeys.has(key)) {
340
+ children.set(key, createNodeForValue(value[key], true));
341
+ }
342
+ }
343
+ // Note: We don't remove keys that are no longer present
344
+ // This maintains reactivity for subscribers to those keys
345
+ }
346
+ subject$.next(value);
347
+ }
348
+ },
349
+ getChild: (key) => {
350
+ // Return undefined if null or no children
351
+ const value = subject$.getValue();
352
+ if (value === null || value === undefined || !children) {
353
+ return undefined;
354
+ }
355
+ return children.get(key);
356
+ },
357
+ getOrCreateChild: (key) => {
358
+ // If we have real children and the key exists, return the real child
359
+ if (children && children.has(key)) {
360
+ return children.get(key);
361
+ }
362
+ // Check pendingChildren for already-created pending nodes
363
+ if (pendingChildren.has(key)) {
364
+ // Even though we have a pending node, if children now exist, return the real child
365
+ // This handles the case where parent was set after pending node was created
366
+ if (children && children.has(key)) {
367
+ return children.get(key);
368
+ }
369
+ return pendingChildren.get(key);
370
+ }
371
+ // Create a "pending" child node that derives its value dynamically
372
+ // When parent is null: emits undefined
373
+ // When parent has value and real children exist: delegates to real child's observable
374
+ // When parent has value but real children don't exist yet: extracts from parent value
375
+ // We use a BehaviorSubject that we manually keep in sync
376
+ const pendingSubject$ = new BehaviorSubject(undefined);
377
+ // Subscribe to parent changes to update pending subject
378
+ const parentSubscription = subject$.subscribe((parentValue) => {
379
+ if (parentValue === null || parentValue === undefined) {
380
+ pendingSubject$.next(undefined);
381
+ }
382
+ else if (children && children.has(key)) {
383
+ // Real child exists - get its current value
384
+ pendingSubject$.next(children.get(key).get());
385
+ }
386
+ else {
387
+ // Extract from parent value
388
+ pendingSubject$.next(parentValue[key]);
389
+ }
390
+ });
391
+ // Also, we need to subscribe to real child changes when it exists
392
+ // We'll do this by tracking when children are created and subscribing
393
+ let realChildSubscription = null;
394
+ const child$ = pendingSubject$.pipe(distinctUntilChanged(), shareReplay(1));
395
+ child$.subscribe(); // Keep hot
396
+ const pendingNode = {
397
+ $: child$,
398
+ get: () => {
399
+ const parentValue = subject$.getValue();
400
+ if (parentValue === null || parentValue === undefined) {
401
+ return undefined;
402
+ }
403
+ // If real children exist now, delegate to them
404
+ if (children && children.has(key)) {
405
+ return children.get(key).get();
406
+ }
407
+ return parentValue[key];
408
+ },
409
+ set: (value) => {
410
+ const parentValue = subject$.getValue();
411
+ if (parentValue === null || parentValue === undefined) {
412
+ // Can't set on null parent - this is a no-op
413
+ return;
414
+ }
415
+ // If real children exist, delegate to them
416
+ if (children && children.has(key)) {
417
+ children.get(key).set(value);
418
+ return;
419
+ }
420
+ // Otherwise update the parent directly
421
+ const newParent = { ...parentValue, [key]: value };
422
+ subject$.next(newParent);
423
+ },
424
+ _subscribeToRealChild: () => {
425
+ // Called when real children are created to subscribe to child changes
426
+ if (children && children.has(key) && !realChildSubscription) {
427
+ realChildSubscription = children.get(key).$.subscribe((value) => {
428
+ pendingSubject$.next(value);
429
+ });
430
+ }
431
+ },
432
+ };
433
+ pendingChildren.set(key, pendingNode);
434
+ return pendingNode;
435
+ },
436
+ isNull: () => {
437
+ const value = subject$.getValue();
438
+ return value === null || value === undefined;
439
+ },
440
+ lock: () => lock$.next(false),
441
+ unlock: () => lock$.next(true),
442
+ subscribeOnce: (callback) => {
443
+ return $.pipe(take(1)).subscribe(callback);
444
+ },
445
+ };
446
+ }
447
+ // Type guard for nullable nodes
448
+ function isNullableNode(node) {
449
+ return NULLABLE_NODE in node;
450
+ }
451
+ // Special node for object elements within arrays
452
+ // These project from the parent array but support nested property access
453
+ function createArrayElementObjectNode(parentArray$, index, initialValue) {
454
+ const keys = Object.keys(initialValue);
455
+ const children = new Map();
456
+ // Create child nodes that project through the array
457
+ for (const key of keys) {
458
+ children.set(key, createArrayElementPropertyNode(parentArray$, index, key, initialValue[key]));
459
+ }
460
+ // Handle empty objects
461
+ if (keys.length === 0) {
462
+ const element$ = parentArray$.pipe(map((arr) => arr[index]), countedDistinctUntilChanged(), shareReplay(1));
463
+ element$.subscribe();
464
+ return {
465
+ $: element$,
466
+ children,
467
+ get: () => parentArray$.getValue()[index],
468
+ set: (v) => {
469
+ const arr = [...parentArray$.getValue()];
470
+ arr[index] = v;
471
+ parentArray$.next(arr);
472
+ },
473
+ };
474
+ }
475
+ // Derive from children
476
+ const childObservables = keys.map((key) => children.get(key).$);
477
+ const $ = combineLatest(childObservables).pipe(map((values) => {
478
+ const result = {};
479
+ keys.forEach((key, i) => {
480
+ result[key] = values[i];
481
+ });
482
+ return result;
483
+ }), countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), shareReplay(1));
484
+ $.subscribe();
485
+ return {
486
+ $,
487
+ children,
488
+ get: () => {
489
+ const result = {};
490
+ for (const [key, child] of children) {
491
+ result[key] = child.get();
492
+ }
493
+ return result;
494
+ },
495
+ set: (v) => {
496
+ // Update parent array directly
497
+ const arr = [...parentArray$.getValue()];
498
+ arr[index] = v;
499
+ parentArray$.next(arr);
500
+ // Note: This causes children to be out of sync until they re-read from parent
501
+ // For simplicity, we update children too
502
+ for (const [key, child] of children) {
503
+ child.set(v[key]);
504
+ }
505
+ },
506
+ };
507
+ }
508
+ // Node for a property of an object inside an array
509
+ function createArrayElementPropertyNode(parentArray$, index, key, initialValue) {
510
+ // If nested object/array, recurse
511
+ if (initialValue !== null && typeof initialValue === "object") {
512
+ if (Array.isArray(initialValue)) {
513
+ // Nested array inside array element - create projection
514
+ return createNestedArrayProjection(parentArray$, index, key, initialValue);
515
+ }
516
+ // Nested object inside array element
517
+ return createNestedObjectProjection(parentArray$, index, key, initialValue);
518
+ }
519
+ // Primitive property
520
+ const prop$ = parentArray$.pipe(map((arr) => arr[index]?.[key]), countedDistinctUntilChanged(), shareReplay(1));
521
+ prop$.subscribe();
522
+ return {
523
+ $: prop$,
524
+ get: () => {
525
+ const arr = parentArray$.getValue();
526
+ return arr[index]?.[key];
527
+ },
528
+ set: (v) => {
529
+ const arr = [...parentArray$.getValue()];
530
+ arr[index] = { ...arr[index], [key]: v };
531
+ parentArray$.next(arr);
532
+ },
533
+ };
534
+ }
535
+ // Nested object projection (object property inside array element)
536
+ function createNestedObjectProjection(parentArray$, index, key, initialValue) {
537
+ const keys = Object.keys(initialValue);
538
+ const children = new Map();
539
+ // For each property of the nested object
540
+ for (const nestedKey of keys) {
541
+ // Create a projection for this nested property
542
+ const nested$ = parentArray$.pipe(map((arr) => {
543
+ const element = arr[index];
544
+ const obj = element?.[key];
545
+ return obj?.[nestedKey];
546
+ }), countedDistinctUntilChanged(), shareReplay(1));
547
+ nested$.subscribe();
548
+ children.set(nestedKey, {
549
+ $: nested$,
550
+ get: () => {
551
+ const arr = parentArray$.getValue();
552
+ const element = arr[index];
553
+ const obj = element?.[key];
554
+ return obj?.[nestedKey];
555
+ },
556
+ set: (v) => {
557
+ const arr = [...parentArray$.getValue()];
558
+ const element = { ...arr[index] };
559
+ element[key] = { ...element[key], [nestedKey]: v };
560
+ arr[index] = element;
561
+ parentArray$.next(arr);
562
+ },
563
+ });
564
+ }
565
+ // Derive observable from children or parent
566
+ const obj$ = parentArray$.pipe(map((arr) => arr[index]?.[key]), countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), shareReplay(1));
567
+ obj$.subscribe();
568
+ return {
569
+ $: obj$,
570
+ children,
571
+ get: () => {
572
+ const arr = parentArray$.getValue();
573
+ return arr[index]?.[key];
574
+ },
575
+ set: (v) => {
576
+ const arr = [...parentArray$.getValue()];
577
+ arr[index] = { ...arr[index], [key]: v };
578
+ parentArray$.next(arr);
579
+ },
580
+ };
581
+ }
582
+ // Nested array projection (array property inside array element)
583
+ function createNestedArrayProjection(parentArray$, index, key, initialValue) {
584
+ const arr$ = parentArray$.pipe(map((arr) => arr[index]?.[key]), countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), shareReplay(1));
585
+ arr$.subscribe();
586
+ return {
587
+ $: arr$,
588
+ get: () => {
589
+ const arr = parentArray$.getValue();
590
+ return arr[index]?.[key];
591
+ },
592
+ set: (v) => {
593
+ const arr = [...parentArray$.getValue()];
594
+ arr[index] = { ...arr[index], [key]: v };
595
+ parentArray$.next(arr);
596
+ },
597
+ };
598
+ }
599
+ // Factory to create the right node type
600
+ // When maybeNullable is true and value is null/undefined, creates a NullableNodeCore
601
+ // that can later be upgraded to an object with children
602
+ function createNodeForValue(value, maybeNullable = false) {
603
+ // Check for nullable marker (from nullable() helper)
604
+ if (isNullableMarked(value)) {
605
+ // Remove the marker before creating the node
606
+ delete value[NULLABLE_MARKER];
607
+ return createNullableObjectNode(value);
608
+ }
609
+ if (value === null || value === undefined) {
610
+ if (maybeNullable) {
611
+ // Create nullable node that can be upgraded to object later
612
+ return createNullableObjectNode(value);
613
+ }
614
+ return createLeafNode(value);
615
+ }
616
+ if (typeof value !== "object") {
617
+ return createLeafNode(value);
618
+ }
619
+ if (Array.isArray(value)) {
620
+ return createArrayNode(value);
621
+ }
622
+ return createObjectNode(value);
623
+ }
624
+ // =============================================================================
625
+ // Proxy Wrapper
626
+ // =============================================================================
627
+ /**
628
+ * Wraps a nullable object node with a proxy that:
629
+ * - Returns undefined for child property access when value is null
630
+ * - Creates/returns wrapped children when value is non-null
631
+ * - Provides update() for batched updates
632
+ */
633
+ function wrapNullableWithProxy(node) {
634
+ // Create update function
635
+ const update = (callback) => {
636
+ node.lock();
637
+ try {
638
+ // Build a proxy for the children
639
+ const childrenProxy = new Proxy({}, {
640
+ get(_, prop) {
641
+ if (typeof prop === "string") {
642
+ const child = node.getChild(prop);
643
+ if (child) {
644
+ return wrapWithProxy(child);
645
+ }
646
+ }
647
+ return undefined;
648
+ },
649
+ });
650
+ callback(childrenProxy);
651
+ }
652
+ finally {
653
+ node.unlock();
654
+ }
655
+ return node.get();
656
+ };
657
+ const proxy = new Proxy(node.$, {
658
+ get(target, prop) {
659
+ // Observable methods
660
+ if (prop === "subscribe")
661
+ return node.$.subscribe.bind(node.$);
662
+ if (prop === "pipe")
663
+ return node.$.pipe.bind(node.$);
664
+ if (prop === "forEach")
665
+ return node.$.forEach?.bind(node.$);
666
+ // Node methods
667
+ if (prop === "get")
668
+ return node.get;
669
+ if (prop === "set")
670
+ return node.set;
671
+ if (prop === "update")
672
+ return update;
673
+ if (prop === "subscribeOnce")
674
+ return node.subscribeOnce;
675
+ if (prop === NODE)
676
+ return node;
677
+ // Symbol.observable for RxJS interop
678
+ if (prop === Symbol.observable || prop === "@@observable") {
679
+ return () => node.$;
680
+ }
681
+ // Child property access - uses getOrCreateChild for deep subscription support
682
+ // This means store.user.age.subscribe() works even when user is null
683
+ if (typeof prop === "string") {
684
+ const child = node.getOrCreateChild(prop);
685
+ return wrapWithProxy(child);
686
+ }
687
+ // Fallback to observable properties
688
+ if (prop in target) {
689
+ const val = target[prop];
690
+ return typeof val === "function" ? val.bind(target) : val;
691
+ }
692
+ return undefined;
693
+ },
694
+ has(_, prop) {
695
+ // When value is non-null and we have children, check if prop exists
696
+ if (!node.isNull() && node.children && typeof prop === "string") {
697
+ return node.children.has(prop);
698
+ }
699
+ return false;
700
+ },
701
+ ownKeys() {
702
+ if (!node.isNull() && node.children) {
703
+ return Array.from(node.children.keys());
704
+ }
705
+ return [];
706
+ },
707
+ getOwnPropertyDescriptor(_, prop) {
708
+ if (!node.isNull() && node.children && typeof prop === "string" && node.children.has(prop)) {
709
+ return { enumerable: true, configurable: true };
710
+ }
711
+ return undefined;
712
+ },
713
+ });
714
+ return proxy;
715
+ }
716
+ function wrapWithProxy(node) {
717
+ // Check for nullable node first (before checking value, since value might be null)
718
+ if (isNullableNode(node)) {
719
+ return wrapNullableWithProxy(node);
720
+ }
721
+ const value = node.get();
722
+ // Primitive - just attach methods to observable
723
+ if (value === null || typeof value !== "object") {
724
+ return Object.assign(node.$, {
725
+ get: node.get,
726
+ set: node.set,
727
+ subscribe: node.$.subscribe.bind(node.$),
728
+ pipe: node.$.pipe.bind(node.$),
729
+ subscribeOnce: node.subscribeOnce,
730
+ [NODE]: node,
731
+ });
732
+ }
733
+ // Array
734
+ if (Array.isArray(value)) {
735
+ const arrayNode = node;
736
+ // Create the wrapped result first so we can reference it in update
737
+ const wrapped = Object.assign(node.$, {
738
+ get: node.get,
739
+ set: node.set,
740
+ subscribe: node.$.subscribe.bind(node.$),
741
+ pipe: node.$.pipe.bind(node.$),
742
+ subscribeOnce: node.subscribeOnce,
743
+ at: (index) => {
744
+ const child = arrayNode.at(index);
745
+ if (!child)
746
+ return undefined;
747
+ return wrapWithProxy(child);
748
+ },
749
+ length: arrayNode.length$,
750
+ push: arrayNode.push,
751
+ pop: arrayNode.pop,
752
+ map: arrayNode.mapItems,
753
+ filter: arrayNode.filterItems,
754
+ update: (callback) => {
755
+ arrayNode.lock(); // Lock - suppress emissions
756
+ try {
757
+ callback(wrapped); // Pass wrapped array so user can use .at(), .push(), etc.
758
+ }
759
+ finally {
760
+ arrayNode.unlock(); // Unlock - emit final state
761
+ }
762
+ return node.get();
763
+ },
764
+ [NODE]: node,
765
+ });
766
+ return wrapped;
767
+ }
768
+ // Object - use Proxy for property access
769
+ const objectNode = node;
770
+ // Create update function that has access to the proxy (defined after proxy creation)
771
+ let updateFn;
772
+ const proxy = new Proxy(node.$, {
773
+ get(target, prop) {
774
+ // Observable methods
775
+ if (prop === "subscribe")
776
+ return node.$.subscribe.bind(node.$);
777
+ if (prop === "pipe")
778
+ return node.$.pipe.bind(node.$);
779
+ if (prop === "forEach")
780
+ return node.$.forEach?.bind(node.$);
781
+ // Node methods
782
+ if (prop === "get")
783
+ return node.get;
784
+ if (prop === "set")
785
+ return node.set;
786
+ if (prop === "update")
787
+ return updateFn;
788
+ if (prop === "subscribeOnce")
789
+ return node.subscribeOnce;
790
+ if (prop === NODE)
791
+ return node;
792
+ // Symbol.observable for RxJS interop
793
+ if (prop === Symbol.observable || prop === "@@observable") {
794
+ return () => node.$;
795
+ }
796
+ // Child property access
797
+ if (objectNode.children && typeof prop === "string") {
798
+ const child = objectNode.children.get(prop);
799
+ if (child) {
800
+ return wrapWithProxy(child);
801
+ }
802
+ }
803
+ // Fallback to observable properties
804
+ if (prop in target) {
805
+ const val = target[prop];
806
+ return typeof val === "function" ? val.bind(target) : val;
807
+ }
808
+ return undefined;
809
+ },
810
+ has(target, prop) {
811
+ if (objectNode.children && typeof prop === "string") {
812
+ return objectNode.children.has(prop);
813
+ }
814
+ return prop in target;
815
+ },
816
+ ownKeys() {
817
+ if (objectNode.children) {
818
+ return Array.from(objectNode.children.keys());
819
+ }
820
+ return [];
821
+ },
822
+ getOwnPropertyDescriptor(target, prop) {
823
+ if (objectNode.children && typeof prop === "string" && objectNode.children.has(prop)) {
824
+ return { enumerable: true, configurable: true };
825
+ }
826
+ return undefined;
827
+ },
828
+ });
829
+ // Now define update function with access to proxy
830
+ if (objectNode.lock && objectNode.unlock) {
831
+ updateFn = (callback) => {
832
+ objectNode.lock(); // Lock - suppress emissions
833
+ try {
834
+ callback(proxy); // Pass the proxy so user can call .set() on children
835
+ }
836
+ finally {
837
+ objectNode.unlock(); // Unlock - emit final state
838
+ }
839
+ return node.get();
840
+ };
841
+ }
842
+ return proxy;
843
+ }
844
+ // =============================================================================
845
+ // Public API
846
+ // =============================================================================
847
+ export function state(initialState) {
848
+ const node = createObjectNode(initialState);
849
+ return wrapWithProxy(node);
850
+ }
851
+ // Symbol to mark a value as nullable
852
+ const NULLABLE_MARKER = Symbol("nullable");
853
+ /**
854
+ * Marks a value as nullable, allowing it to transition between null and object.
855
+ * Use this when you want to start with an object value but later set it to null.
856
+ *
857
+ * @example
858
+ * const store = state({
859
+ * // Can start with object and later be set to null
860
+ * user: nullable({ name: "Alice", age: 30 }),
861
+ * // Can start with null and later be set to object
862
+ * profile: nullable<{ bio: string }>(null),
863
+ * });
864
+ *
865
+ * // Use ?. on the nullable property, then access children directly
866
+ * store.user?.set(null); // Works!
867
+ * store.user?.set({ name: "Bob", age: 25 }); // Works!
868
+ * store.user?.name.set("Charlie"); // After ?. on user, children are directly accessible
869
+ */
870
+ export function nullable(value) {
871
+ if (value === null) {
872
+ return null;
873
+ }
874
+ // Mark the object so createNodeForValue knows to use NullableNodeCore
875
+ return Object.assign(value, { [NULLABLE_MARKER]: true });
876
+ }
877
+ // Check if a value was marked as nullable
878
+ function isNullableMarked(value) {
879
+ return value !== null && typeof value === "object" && NULLABLE_MARKER in value;
880
+ }
881
+ //# sourceMappingURL=deepstate.js.map