@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,1332 @@
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
+
14
+ import { BehaviorSubject, Observable, combineLatest, of, Subscription } from "rxjs";
15
+ import {
16
+ map,
17
+ distinctUntilChanged,
18
+ shareReplay,
19
+ take,
20
+ filter,
21
+ } from "rxjs/operators";
22
+
23
+ // =============================================================================
24
+ // Counters for performance comparison
25
+ // =============================================================================
26
+
27
+ export let distinctCallCount = 0;
28
+ export function resetDistinctCallCount() {
29
+ distinctCallCount = 0;
30
+ }
31
+
32
+ // Wrap distinctUntilChanged to count calls
33
+ function countedDistinctUntilChanged<T>(compareFn?: (a: T, b: T) => boolean) {
34
+ return distinctUntilChanged<T>((a, b) => {
35
+ distinctCallCount++;
36
+ if (compareFn) return compareFn(a, b);
37
+ return a === b;
38
+ });
39
+ }
40
+
41
+ // =============================================================================
42
+ // Deep Freeze
43
+ // =============================================================================
44
+
45
+ function deepFreeze<T>(obj: T): T {
46
+ if (obj === null || typeof obj !== "object") return obj;
47
+ if (Object.isFrozen(obj)) return obj;
48
+
49
+ Object.freeze(obj);
50
+
51
+ if (Array.isArray(obj)) {
52
+ obj.forEach((item) => deepFreeze(item));
53
+ } else {
54
+ Object.keys(obj).forEach((key) => {
55
+ deepFreeze((obj as Record<string, unknown>)[key]);
56
+ });
57
+ }
58
+
59
+ return obj;
60
+ }
61
+
62
+ // =============================================================================
63
+ // Types
64
+ // =============================================================================
65
+
66
+ type Primitive = string | number | boolean | null | undefined | symbol | bigint;
67
+
68
+ // Type utilities for nullable object detection
69
+ type NonNullablePart<T> = T extends null | undefined ? never : T;
70
+
71
+ // Check if T includes null or undefined
72
+ type HasNull<T> = null extends T ? true : false;
73
+ type HasUndefined<T> = undefined extends T ? true : false;
74
+ type IsNullish<T> = HasNull<T> extends true ? true : HasUndefined<T>;
75
+
76
+ // Check if the non-nullable part is an object (but not array)
77
+ type NonNullPartIsObject<T> = NonNullablePart<T> extends object
78
+ ? NonNullablePart<T> extends Array<unknown>
79
+ ? false
80
+ : true
81
+ : false;
82
+
83
+ // A nullable object is: has null/undefined in union AND non-null part is an object
84
+ type IsNullableObject<T> = IsNullish<T> extends true
85
+ ? NonNullPartIsObject<T>
86
+ : false;
87
+
88
+ /**
89
+ * Deep readonly type - makes all nested properties readonly.
90
+ * Used for return types of get() and subscribe() to prevent accidental mutations.
91
+ */
92
+ export type DeepReadonly<T> = [T] extends [Primitive]
93
+ ? T
94
+ : [T] extends [Array<infer U>]
95
+ ? ReadonlyArray<DeepReadonly<U>>
96
+ : [T] extends [object]
97
+ ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
98
+ : T;
99
+
100
+ /**
101
+ * A mutable draft of state T for use in update callbacks.
102
+ */
103
+ export type Draft<T> = T;
104
+
105
+ // Internal node interface - what every node must implement
106
+ interface NodeCore<T> {
107
+ readonly $: Observable<T>;
108
+ get(): T;
109
+ set(value: T): void;
110
+ subscribeOnce?(callback: (value: T) => void): Subscription;
111
+ }
112
+
113
+ // Symbols for internal access
114
+ const NODE = Symbol("node");
115
+
116
+ // External API types
117
+ type RxLeaf<T> = Observable<DeepReadonly<T>> & {
118
+ /** Get current value synchronously */
119
+ get(): DeepReadonly<T>;
120
+ /** Set value */
121
+ set(value: T): void;
122
+ /** Subscribe to a single emission, then automatically unsubscribe */
123
+ subscribeOnce(callback: (value: DeepReadonly<T>) => void): Subscription;
124
+ [NODE]: NodeCore<T>;
125
+ };
126
+
127
+ type RxObject<T extends object> = {
128
+ [K in keyof T]: RxNodeFor<T[K]>;
129
+ } & Observable<DeepReadonly<T>> & {
130
+ /** Get current value synchronously */
131
+ get(): DeepReadonly<T>;
132
+ /** Set value */
133
+ set(value: T): void;
134
+ /**
135
+ * Update multiple properties in a single emission.
136
+ * The callback receives the reactive state object - use .set() on properties to update them.
137
+ * All changes are batched into a single emission.
138
+ * @example
139
+ * store.user.update(draft => {
140
+ * draft.name.set("Bob");
141
+ * draft.age.set(31);
142
+ * });
143
+ */
144
+ update(callback: (draft: RxObject<T>) => void): DeepReadonly<T>;
145
+ /** Subscribe to a single emission, then automatically unsubscribe */
146
+ subscribeOnce(callback: (value: DeepReadonly<T>) => void): Subscription;
147
+ [NODE]: NodeCore<T>;
148
+ };
149
+
150
+ type RxArray<T> = Observable<DeepReadonly<T[]>> & {
151
+ /** Get current value synchronously */
152
+ get(): DeepReadonly<T[]>;
153
+ /** Set value */
154
+ set(value: T[]): void;
155
+ /**
156
+ * Update array in a single emission.
157
+ * The callback receives the reactive array - use .at(), .push(), .pop() etc to update.
158
+ * All changes are batched into a single emission.
159
+ * @example
160
+ * store.items.update(draft => {
161
+ * draft.at(0)?.name.set("Updated");
162
+ * draft.push({ id: 2, name: "New" });
163
+ * });
164
+ */
165
+ update(callback: (draft: RxArray<T>) => void): DeepReadonly<T[]>;
166
+ /** Subscribe to a single emission, then automatically unsubscribe */
167
+ subscribeOnce(callback: (value: DeepReadonly<T[]>) => void): Subscription;
168
+ /** Get reactive node for array element at index */
169
+ at(index: number): RxNodeFor<T> | undefined;
170
+ /** Get current length (also observable) */
171
+ length: Observable<number> & { get(): number };
172
+ /** Push items and return new length */
173
+ push(...items: T[]): number;
174
+ /** Pop last item */
175
+ pop(): DeepReadonly<T> | undefined;
176
+ /** Map over current values (non-reactive, use .subscribe for reactive) */
177
+ map<U>(fn: (item: DeepReadonly<T>, index: number) => U): U[];
178
+ /** Filter current values */
179
+ filter(fn: (item: DeepReadonly<T>, index: number) => boolean): DeepReadonly<T>[];
180
+ [NODE]: NodeCore<T[]>;
181
+ };
182
+
183
+ /**
184
+ * RxNullable - For properties typed as `{ ... } | null` or `{ ... } | undefined`
185
+ *
186
+ * The node is always present at runtime, enabling deep subscription:
187
+ * - You can subscribe to `store.user.name` even when `user` is null
188
+ * - The subscription will emit `undefined` while user is null
189
+ * - Once user is set to an object, the subscription will emit the actual name value
190
+ *
191
+ * @example
192
+ * const store = state<{ user: { name: string } | null }>({ user: null });
193
+ *
194
+ * // Deep subscription works even when user is null
195
+ * store.user.name.subscribe(name => {
196
+ * console.log(name); // undefined when user is null, actual value when set
197
+ * });
198
+ *
199
+ * store.user.get(); // null
200
+ * store.user.set({ name: "Alice" }); // Now name subscription emits "Alice"
201
+ * store.user.name.get(); // "Alice"
202
+ * store.user.name.set("Bob"); // Works!
203
+ */
204
+ type RxNullable<T, TNonNull extends object = NonNullablePart<T> & object> = Observable<DeepReadonly<T>> & {
205
+ /** Get current value (may be null/undefined) */
206
+ get(): DeepReadonly<T>;
207
+ /** Set value (can be null/undefined or the full object) */
208
+ set(value: T): void;
209
+ /** Subscribe to a single emission, then automatically unsubscribe */
210
+ subscribeOnce(callback: (value: DeepReadonly<T>) => void): Subscription;
211
+ /**
212
+ * Update multiple properties in a single emission.
213
+ * @example
214
+ * store.user.update(user => {
215
+ * user.name.set("Bob");
216
+ * user.age.set(31);
217
+ * });
218
+ */
219
+ update(callback: (draft: RxObject<TNonNull>) => void): DeepReadonly<T>;
220
+ [NODE]: NodeCore<T>;
221
+ } & {
222
+ /**
223
+ * Child properties - always accessible, even when parent value is null.
224
+ * When parent is null, children emit undefined. When parent has a value,
225
+ * children emit their actual values.
226
+ */
227
+ [K in keyof TNonNull]: RxNullableChild<TNonNull[K]>;
228
+ };
229
+
230
+ /**
231
+ * Type for children of a nullable object.
232
+ * Children are wrapped to handle the case where parent is null:
233
+ * - When parent is null: get() returns undefined, subscribe emits undefined
234
+ * - When parent has value: behaves like normal RxNodeFor
235
+ */
236
+ type RxNullableChild<T> =
237
+ // For nested nullable objects, use RxNullable (allows further deep subscription)
238
+ IsNullableObject<T> extends true
239
+ ? RxNullable<T>
240
+ // For primitives, wrap with undefined union since parent might be null
241
+ : [T] extends [Primitive]
242
+ ? RxLeaf<T | undefined>
243
+ // For arrays under nullable parent
244
+ : [T] extends [Array<infer U>]
245
+ ? RxArray<U> | RxLeaf<undefined>
246
+ // For objects under nullable parent
247
+ : [T] extends [object]
248
+ ? RxNullableChildObject<T>
249
+ // Fallback
250
+ : RxLeaf<T | undefined>;
251
+
252
+ /**
253
+ * Type for object children under a nullable parent.
254
+ * The object itself might be undefined (if parent is null), but if present
255
+ * it has all the normal object methods and children.
256
+ */
257
+ type RxNullableChildObject<T extends object> = Observable<DeepReadonly<T> | undefined> & {
258
+ get(): DeepReadonly<T> | undefined;
259
+ set(value: T): void;
260
+ subscribeOnce(callback: (value: DeepReadonly<T> | undefined) => void): Subscription;
261
+ [NODE]: NodeCore<T | undefined>;
262
+ } & {
263
+ [K in keyof T]: RxNullableChild<T[K]>;
264
+ };
265
+
266
+ type RxNodeFor<T> =
267
+ // First: check for nullable object (e.g., { name: string } | null)
268
+ IsNullableObject<T> extends true
269
+ ? RxNullable<T>
270
+ // Then: primitives (including plain null/undefined)
271
+ : [T] extends [Primitive]
272
+ ? RxLeaf<T>
273
+ // Then: arrays
274
+ : [T] extends [Array<infer U>]
275
+ ? RxArray<U>
276
+ // Then: plain objects
277
+ : [T] extends [object]
278
+ ? RxObject<T>
279
+ // Fallback
280
+ : RxLeaf<T>;
281
+
282
+ export type RxState<T extends object> = RxObject<T>;
283
+
284
+ // =============================================================================
285
+ // Node Creation
286
+ // =============================================================================
287
+
288
+ function createLeafNode<T extends Primitive>(value: T): NodeCore<T> {
289
+ const subject$ = new BehaviorSubject<T>(value);
290
+
291
+ // Use distinctUntilChanged to prevent duplicate emissions for same value
292
+ const distinct$ = subject$.pipe(
293
+ distinctUntilChanged(),
294
+ shareReplay(1)
295
+ );
296
+ // Keep hot
297
+ distinct$.subscribe();
298
+
299
+ return {
300
+ $: distinct$,
301
+ get: () => subject$.getValue(),
302
+ set: (v: T) => subject$.next(v),
303
+ subscribeOnce: (callback: (value: T) => void): Subscription => {
304
+ return distinct$.pipe(take(1)).subscribe(callback);
305
+ },
306
+ };
307
+ }
308
+
309
+ function createObjectNode<T extends object>(value: T): NodeCore<T> & {
310
+ children: Map<string, NodeCore<unknown>>;
311
+ lock(): void;
312
+ unlock(): void;
313
+ } {
314
+ const keys = Object.keys(value) as (keyof T)[];
315
+ const children = new Map<keyof T, NodeCore<unknown>>();
316
+
317
+ // Create child nodes for each property
318
+ // Pass maybeNullable: true so null values get NullableNodeCore
319
+ // which can be upgraded to objects later
320
+ for (const key of keys) {
321
+ children.set(key, createNodeForValue(value[key], true));
322
+ }
323
+
324
+ // Helper to get current value from children
325
+ const getCurrentValue = (): T => {
326
+ const result = {} as T;
327
+ for (const [key, child] of children) {
328
+ (result as Record<string, unknown>)[key as string] = child.get();
329
+ }
330
+ return result;
331
+ };
332
+
333
+ // Handle empty objects
334
+ if (keys.length === 0) {
335
+ const empty$ = of(value).pipe(shareReplay(1));
336
+ return {
337
+ $: empty$,
338
+ children: children as Map<string, NodeCore<unknown>>,
339
+ get: () => ({}) as T,
340
+ set: () => {}, // No-op for empty objects
341
+ lock: () => {}, // No-op for empty objects
342
+ unlock: () => {}, // No-op for empty objects
343
+ };
344
+ }
345
+
346
+ // Lock for batching updates - when false, emissions are filtered out
347
+ const lock$ = new BehaviorSubject<boolean>(true);
348
+
349
+ // Derive observable from children + lock using combineLatest
350
+ const childObservables = keys.map((key) => children.get(key)!.$);
351
+
352
+ const $ = combineLatest([...childObservables, lock$] as Observable<unknown>[]).pipe(
353
+ // Only emit when unlocked (lock is last element)
354
+ filter((values) => values[values.length - 1] === true),
355
+ // Remove lock value from output, reconstruct object
356
+ map((values) => {
357
+ const result = {} as T;
358
+ keys.forEach((key, i) => {
359
+ (result as Record<string, unknown>)[key as string] = values[i];
360
+ });
361
+ return result;
362
+ }),
363
+ shareReplay(1)
364
+ );
365
+
366
+ // Force subscription to make it hot (so emissions work even before external subscribers)
367
+ $.subscribe();
368
+
369
+ // Create a version that freezes on emission
370
+ const frozen$ = $.pipe(map(deepFreeze));
371
+
372
+ return {
373
+ $: frozen$,
374
+ children: children as Map<string, NodeCore<unknown>>,
375
+ get: () => deepFreeze(getCurrentValue()),
376
+ set: (v: T) => {
377
+ for (const [key, child] of children) {
378
+ child.set(v[key]);
379
+ }
380
+ },
381
+ lock: () => lock$.next(false),
382
+ unlock: () => lock$.next(true),
383
+ // Note: update() is implemented in wrapWithProxy since it needs the proxy reference
384
+ subscribeOnce: (callback: (value: T) => void): Subscription => {
385
+ return frozen$.pipe(take(1)).subscribe(callback);
386
+ },
387
+ };
388
+ }
389
+
390
+ function createArrayNode<T>(value: T[]): NodeCore<T[]> & {
391
+ at(index: number): NodeCore<T> | undefined;
392
+ childCache: Map<number, NodeCore<T>>;
393
+ length$: Observable<number> & { get(): number };
394
+ push(...items: T[]): number;
395
+ pop(): T | undefined;
396
+ mapItems<U>(fn: (item: T, index: number) => U): U[];
397
+ filterItems(fn: (item: T, index: number) => boolean): T[];
398
+ lock(): void;
399
+ unlock(): void;
400
+ } {
401
+ const subject$ = new BehaviorSubject<T[]>([...value]);
402
+ const childCache = new Map<number, NodeCore<T>>();
403
+
404
+ const createChildProjection = (index: number): NodeCore<T> => {
405
+ const currentValue = subject$.getValue()[index];
406
+
407
+ // If the element is an object, we need nested access
408
+ // Create a "projection node" that reads/writes through the parent array
409
+ if (currentValue !== null && typeof currentValue === "object") {
410
+ return createArrayElementObjectNode(
411
+ subject$ as unknown as BehaviorSubject<unknown[]>,
412
+ index,
413
+ currentValue as object
414
+ ) as unknown as NodeCore<T>;
415
+ }
416
+
417
+ // Primitive element - simple projection
418
+ const element$ = subject$.pipe(
419
+ map((arr) => arr[index]),
420
+ countedDistinctUntilChanged(),
421
+ shareReplay(1)
422
+ );
423
+
424
+ // Force hot
425
+ element$.subscribe();
426
+
427
+ return {
428
+ $: element$ as Observable<T>,
429
+ get: () => subject$.getValue()[index] as T,
430
+ set: (v: T) => {
431
+ const arr = [...subject$.getValue()];
432
+ arr[index] = v;
433
+ subject$.next(arr);
434
+ },
435
+ subscribeOnce: (callback: (value: T) => void): Subscription => {
436
+ return element$.pipe(take(1)).subscribe(callback as (value: unknown) => void);
437
+ },
438
+ };
439
+ };
440
+
441
+ // Lock for batching updates - when false, emissions are filtered out
442
+ const lock$ = new BehaviorSubject<boolean>(true);
443
+
444
+ // Create observable that respects lock
445
+ const locked$ = combineLatest([subject$, lock$]).pipe(
446
+ filter(([_, unlocked]) => unlocked),
447
+ map(([arr, _]) => arr),
448
+ map(deepFreeze),
449
+ shareReplay(1)
450
+ );
451
+ locked$.subscribe(); // Keep hot
452
+
453
+ // Length observable (also respects lock)
454
+ const length$ = locked$.pipe(
455
+ map((arr) => arr.length),
456
+ distinctUntilChanged(),
457
+ shareReplay(1)
458
+ );
459
+ length$.subscribe(); // Keep hot
460
+
461
+ const lengthWithGet = Object.assign(length$, {
462
+ get: () => subject$.getValue().length,
463
+ });
464
+
465
+ return {
466
+ $: locked$ as Observable<T[]>,
467
+ childCache,
468
+ get: () => deepFreeze([...subject$.getValue()]) as T[],
469
+ set: (v: T[]) => {
470
+ // Clear child cache when array is replaced
471
+ childCache.clear();
472
+ subject$.next([...v]);
473
+ },
474
+ subscribeOnce: (callback: (value: T[]) => void): Subscription => {
475
+ return locked$.pipe(take(1)).subscribe(callback);
476
+ },
477
+ at: (index: number) => {
478
+ const arr = subject$.getValue();
479
+ if (index < 0 || index >= arr.length) return undefined;
480
+
481
+ if (!childCache.has(index)) {
482
+ childCache.set(index, createChildProjection(index));
483
+ }
484
+ return childCache.get(index);
485
+ },
486
+ length$: lengthWithGet,
487
+ push: (...items: T[]): number => {
488
+ const current = subject$.getValue();
489
+ const newArr = [...current, ...items];
490
+ subject$.next(newArr);
491
+ return newArr.length;
492
+ },
493
+ pop: (): T | undefined => {
494
+ const current = subject$.getValue();
495
+ if (current.length === 0) return undefined;
496
+ const last = current[current.length - 1];
497
+ // Clear cached node for popped index
498
+ childCache.delete(current.length - 1);
499
+ subject$.next(current.slice(0, -1));
500
+ return deepFreeze(last) as T;
501
+ },
502
+ mapItems: <U>(fn: (item: T, index: number) => U): U[] => {
503
+ return subject$.getValue().map((item, i) => fn(deepFreeze(item) as T, i));
504
+ },
505
+ filterItems: (fn: (item: T, index: number) => boolean): T[] => {
506
+ return deepFreeze(subject$.getValue().filter((item, i) => fn(deepFreeze(item) as T, i))) as T[];
507
+ },
508
+ lock: () => lock$.next(false),
509
+ unlock: () => lock$.next(true),
510
+ // Note: update() is implemented in wrapWithProxy since it needs the proxy reference
511
+ };
512
+ }
513
+
514
+ // Symbol to mark nullable nodes
515
+ const NULLABLE_NODE = Symbol("nullableNode");
516
+
517
+ // Interface for nullable object nodes
518
+ interface NullableNodeCore<T> extends NodeCore<T> {
519
+ [NULLABLE_NODE]: true;
520
+ children: Map<string, NodeCore<unknown>> | null;
521
+ /**
522
+ * Gets or creates a child node for the given key.
523
+ * If the parent is null and the child doesn't exist yet, creates a "pending" child
524
+ * that derives from the parent and emits undefined until the parent is set to an object.
525
+ */
526
+ getChild(key: string): NodeCore<unknown> | undefined;
527
+ /**
528
+ * Gets or creates a child node that supports deep subscription.
529
+ * Unlike getChild, this always returns a node (creating one if needed) so that
530
+ * subscriptions work even when the parent is null.
531
+ */
532
+ getOrCreateChild(key: string): NodeCore<unknown>;
533
+ lock(): void;
534
+ unlock(): void;
535
+ isNull(): boolean;
536
+ }
537
+
538
+ /**
539
+ * Creates a node for nullable object types like `{ name: string } | null`
540
+ *
541
+ * When value is null: no children exist, child access returns undefined
542
+ * When value is set to object: children are created lazily from the object's keys
543
+ */
544
+ function createNullableObjectNode<T>(
545
+ initialValue: T
546
+ ): NullableNodeCore<T> {
547
+ // Subject holds the raw value (null or object)
548
+ const subject$ = new BehaviorSubject<T>(initialValue);
549
+
550
+ // Children are created lazily when we have an actual object
551
+ let children: Map<string, NodeCore<unknown>> | null = null;
552
+
553
+ // Pending children - created for deep subscription before parent has a value
554
+ // These are "projection" nodes that derive from the parent observable
555
+ const pendingChildren = new Map<string, NodeCore<unknown>>();
556
+
557
+ // Lock for batching updates
558
+ const lock$ = new BehaviorSubject<boolean>(true);
559
+
560
+ // Build/rebuild children from an object value
561
+ const buildChildren = (obj: object) => {
562
+ const keys = Object.keys(obj);
563
+ children = new Map();
564
+
565
+ for (const key of keys) {
566
+ children.set(key, createNodeForValue((obj as Record<string, unknown>)[key]));
567
+ }
568
+ };
569
+
570
+ // Initialize children if starting with an object
571
+ if (initialValue !== null && initialValue !== undefined && typeof initialValue === "object") {
572
+ buildChildren(initialValue);
573
+ }
574
+
575
+ // Helper to get current value
576
+ const getCurrentValue = (): T => {
577
+ const raw = subject$.getValue();
578
+ if (raw === null || raw === undefined || !children) {
579
+ return raw;
580
+ }
581
+ // Build value from children
582
+ const result = {} as Record<string, unknown>;
583
+ for (const [key, child] of children) {
584
+ result[key] = child.get();
585
+ }
586
+ return result as T;
587
+ };
588
+
589
+ // Observable that emits the current value, respecting lock
590
+ const $ = combineLatest([subject$, lock$]).pipe(
591
+ filter(([_, unlocked]) => unlocked),
592
+ map(([value, _]) => {
593
+ if (value === null || value === undefined || !children) {
594
+ return value;
595
+ }
596
+ // Build from children for consistency
597
+ const result = {} as Record<string, unknown>;
598
+ for (const [key, child] of children) {
599
+ result[key] = child.get();
600
+ }
601
+ return result as T;
602
+ }),
603
+ distinctUntilChanged((a, b) => {
604
+ if (a === null || a === undefined) return a === b;
605
+ if (b === null || b === undefined) return false;
606
+ return JSON.stringify(a) === JSON.stringify(b);
607
+ }),
608
+ map(deepFreeze),
609
+ shareReplay(1)
610
+ );
611
+ $.subscribe(); // Keep hot
612
+
613
+ // Create a stable reference object that we can update
614
+ const nodeState: { children: Map<string, NodeCore<unknown>> | null } = { children };
615
+
616
+ // Wrapper to update the children reference
617
+ const updateChildrenRef = () => {
618
+ nodeState.children = children;
619
+ };
620
+
621
+ // Override buildChildren to update the reference and connect pending children
622
+ const buildChildrenAndUpdate = (obj: object) => {
623
+ const keys = Object.keys(obj);
624
+ children = new Map();
625
+
626
+ for (const key of keys) {
627
+ // Pass maybeNullable: true so nested nulls also become nullable nodes
628
+ children.set(key, createNodeForValue((obj as Record<string, unknown>)[key], true));
629
+ }
630
+ updateChildrenRef();
631
+
632
+ // Connect pending children to their real counterparts
633
+ for (const [key, pendingNode] of pendingChildren) {
634
+ if (children.has(key) && '_subscribeToRealChild' in pendingNode) {
635
+ (pendingNode as { _subscribeToRealChild: () => void })._subscribeToRealChild();
636
+ }
637
+ }
638
+ };
639
+
640
+ // Re-initialize if starting with object (using updated builder)
641
+ if (initialValue !== null && initialValue !== undefined && typeof initialValue === "object") {
642
+ children = null; // Reset
643
+ buildChildrenAndUpdate(initialValue);
644
+ }
645
+
646
+ return {
647
+ [NULLABLE_NODE]: true as const,
648
+ $,
649
+ get children() { return nodeState.children; },
650
+
651
+ get: () => deepFreeze(getCurrentValue()),
652
+
653
+ set: (value: T) => {
654
+ if (value === null || value === undefined) {
655
+ // Setting to null - keep children structure for potential reuse but emit null
656
+ subject$.next(value);
657
+ } else if (typeof value === "object") {
658
+ // Setting to object
659
+ if (!children) {
660
+ // First time setting an object - create children
661
+ buildChildrenAndUpdate(value);
662
+ } else {
663
+ // Update existing children + handle new/removed keys
664
+ const newKeys = new Set(Object.keys(value));
665
+ const existingKeys = new Set(children.keys());
666
+
667
+ // Update existing children
668
+ for (const [key, child] of children) {
669
+ if (newKeys.has(key)) {
670
+ child.set((value as Record<string, unknown>)[key]);
671
+ }
672
+ }
673
+
674
+ // Add new keys
675
+ for (const key of newKeys) {
676
+ if (!existingKeys.has(key)) {
677
+ children.set(key, createNodeForValue((value as Record<string, unknown>)[key], true));
678
+ }
679
+ }
680
+
681
+ // Note: We don't remove keys that are no longer present
682
+ // This maintains reactivity for subscribers to those keys
683
+ }
684
+ subject$.next(value);
685
+ }
686
+ },
687
+
688
+ getChild: (key: string) => {
689
+ // Return undefined if null or no children
690
+ const value = subject$.getValue();
691
+ if (value === null || value === undefined || !children) {
692
+ return undefined;
693
+ }
694
+ return children.get(key);
695
+ },
696
+
697
+ getOrCreateChild: (key: string): NodeCore<unknown> => {
698
+ // If we have real children and the key exists, return the real child
699
+ if (children && children.has(key)) {
700
+ return children.get(key)!;
701
+ }
702
+
703
+ // Check pendingChildren for already-created pending nodes
704
+ if (pendingChildren.has(key)) {
705
+ // Even though we have a pending node, if children now exist, return the real child
706
+ // This handles the case where parent was set after pending node was created
707
+ if (children && children.has(key)) {
708
+ return children.get(key)!;
709
+ }
710
+ return pendingChildren.get(key)!;
711
+ }
712
+
713
+ // Create a "pending" child node that derives its value dynamically
714
+ // When parent is null: emits undefined
715
+ // When parent has value and real children exist: delegates to real child's observable
716
+ // When parent has value but real children don't exist yet: extracts from parent value
717
+
718
+ // We use a BehaviorSubject that we manually keep in sync
719
+ const pendingSubject$ = new BehaviorSubject<unknown>(undefined);
720
+
721
+ // Subscribe to parent changes to update pending subject
722
+ const parentSubscription = subject$.subscribe((parentValue) => {
723
+ if (parentValue === null || parentValue === undefined) {
724
+ pendingSubject$.next(undefined);
725
+ } else if (children && children.has(key)) {
726
+ // Real child exists - get its current value
727
+ pendingSubject$.next(children.get(key)!.get());
728
+ } else {
729
+ // Extract from parent value
730
+ pendingSubject$.next((parentValue as Record<string, unknown>)[key]);
731
+ }
732
+ });
733
+
734
+ // Also, we need to subscribe to real child changes when it exists
735
+ // We'll do this by tracking when children are created and subscribing
736
+ let realChildSubscription: Subscription | null = null;
737
+
738
+ const child$ = pendingSubject$.pipe(
739
+ distinctUntilChanged(),
740
+ shareReplay(1)
741
+ );
742
+ child$.subscribe(); // Keep hot
743
+
744
+ const pendingNode: NodeCore<unknown> & { _subscribeToRealChild: () => void } = {
745
+ $: child$,
746
+ get: () => {
747
+ const parentValue = subject$.getValue();
748
+ if (parentValue === null || parentValue === undefined) {
749
+ return undefined;
750
+ }
751
+ // If real children exist now, delegate to them
752
+ if (children && children.has(key)) {
753
+ return children.get(key)!.get();
754
+ }
755
+ return (parentValue as Record<string, unknown>)[key];
756
+ },
757
+ set: (value: unknown) => {
758
+ const parentValue = subject$.getValue();
759
+ if (parentValue === null || parentValue === undefined) {
760
+ // Can't set on null parent - this is a no-op
761
+ return;
762
+ }
763
+ // If real children exist, delegate to them
764
+ if (children && children.has(key)) {
765
+ children.get(key)!.set(value);
766
+ return;
767
+ }
768
+ // Otherwise update the parent directly
769
+ const newParent = { ...(parentValue as object), [key]: value };
770
+ subject$.next(newParent as T);
771
+ },
772
+ _subscribeToRealChild: () => {
773
+ // Called when real children are created to subscribe to child changes
774
+ if (children && children.has(key) && !realChildSubscription) {
775
+ realChildSubscription = children.get(key)!.$.subscribe((value) => {
776
+ pendingSubject$.next(value);
777
+ });
778
+ }
779
+ },
780
+ };
781
+
782
+ pendingChildren.set(key, pendingNode);
783
+ return pendingNode;
784
+ },
785
+
786
+ isNull: () => {
787
+ const value = subject$.getValue();
788
+ return value === null || value === undefined;
789
+ },
790
+
791
+ lock: () => lock$.next(false),
792
+ unlock: () => lock$.next(true),
793
+
794
+ subscribeOnce: (callback: (value: T) => void): Subscription => {
795
+ return $.pipe(take(1)).subscribe(callback);
796
+ },
797
+ };
798
+ }
799
+
800
+ // Type guard for nullable nodes
801
+ function isNullableNode<T>(node: NodeCore<T>): node is NullableNodeCore<T> {
802
+ return NULLABLE_NODE in node;
803
+ }
804
+
805
+ // Special node for object elements within arrays
806
+ // These project from the parent array but support nested property access
807
+ function createArrayElementObjectNode<T extends object>(
808
+ parentArray$: BehaviorSubject<unknown[]>,
809
+ index: number,
810
+ initialValue: T
811
+ ): NodeCore<T> & { children: Map<string, NodeCore<unknown>> } {
812
+ const keys = Object.keys(initialValue) as (keyof T)[];
813
+ const children = new Map<string, NodeCore<unknown>>();
814
+
815
+ // Create child nodes that project through the array
816
+ for (const key of keys) {
817
+ children.set(
818
+ key as string,
819
+ createArrayElementPropertyNode(parentArray$, index, key as string, initialValue[key])
820
+ );
821
+ }
822
+
823
+ // Handle empty objects
824
+ if (keys.length === 0) {
825
+ const element$ = parentArray$.pipe(
826
+ map((arr) => arr[index] as T),
827
+ countedDistinctUntilChanged(),
828
+ shareReplay(1)
829
+ );
830
+ element$.subscribe();
831
+
832
+ return {
833
+ $: element$,
834
+ children,
835
+ get: () => parentArray$.getValue()[index] as T,
836
+ set: (v: T) => {
837
+ const arr = [...parentArray$.getValue()];
838
+ arr[index] = v;
839
+ parentArray$.next(arr);
840
+ },
841
+ };
842
+ }
843
+
844
+ // Derive from children
845
+ const childObservables = keys.map((key) => children.get(key as string)!.$);
846
+
847
+ const $ = combineLatest(childObservables).pipe(
848
+ map((values) => {
849
+ const result = {} as T;
850
+ keys.forEach((key, i) => {
851
+ (result as Record<string, unknown>)[key as string] = values[i];
852
+ });
853
+ return result;
854
+ }),
855
+ countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
856
+ shareReplay(1)
857
+ );
858
+
859
+ $.subscribe();
860
+
861
+ return {
862
+ $,
863
+ children,
864
+ get: () => {
865
+ const result = {} as T;
866
+ for (const [key, child] of children) {
867
+ (result as Record<string, unknown>)[key] = child.get();
868
+ }
869
+ return result;
870
+ },
871
+ set: (v: T) => {
872
+ // Update parent array directly
873
+ const arr = [...parentArray$.getValue()];
874
+ arr[index] = v;
875
+ parentArray$.next(arr);
876
+ // Note: This causes children to be out of sync until they re-read from parent
877
+ // For simplicity, we update children too
878
+ for (const [key, child] of children) {
879
+ child.set((v as Record<string, unknown>)[key]);
880
+ }
881
+ },
882
+ };
883
+ }
884
+
885
+ // Node for a property of an object inside an array
886
+ function createArrayElementPropertyNode<T>(
887
+ parentArray$: BehaviorSubject<unknown[]>,
888
+ index: number,
889
+ key: string,
890
+ initialValue: T
891
+ ): NodeCore<T> {
892
+ // If nested object/array, recurse
893
+ if (initialValue !== null && typeof initialValue === "object") {
894
+ if (Array.isArray(initialValue)) {
895
+ // Nested array inside array element - create projection
896
+ return createNestedArrayProjection(parentArray$, index, key, initialValue) as unknown as NodeCore<T>;
897
+ }
898
+ // Nested object inside array element
899
+ return createNestedObjectProjection(parentArray$, index, key, initialValue as object) as unknown as NodeCore<T>;
900
+ }
901
+
902
+ // Primitive property
903
+ const prop$ = parentArray$.pipe(
904
+ map((arr) => (arr[index] as Record<string, unknown>)?.[key] as T),
905
+ countedDistinctUntilChanged(),
906
+ shareReplay(1)
907
+ );
908
+
909
+ prop$.subscribe();
910
+
911
+ return {
912
+ $: prop$,
913
+ get: () => {
914
+ const arr = parentArray$.getValue();
915
+ return (arr[index] as Record<string, unknown>)?.[key] as T;
916
+ },
917
+ set: (v: T) => {
918
+ const arr = [...parentArray$.getValue()];
919
+ arr[index] = { ...(arr[index] as object), [key]: v };
920
+ parentArray$.next(arr);
921
+ },
922
+ };
923
+ }
924
+
925
+ // Nested object projection (object property inside array element)
926
+ function createNestedObjectProjection<T extends object>(
927
+ parentArray$: BehaviorSubject<unknown[]>,
928
+ index: number,
929
+ key: string,
930
+ initialValue: T
931
+ ): NodeCore<T> & { children: Map<string, NodeCore<unknown>> } {
932
+ const keys = Object.keys(initialValue) as (keyof T)[];
933
+ const children = new Map<string, NodeCore<unknown>>();
934
+
935
+ // For each property of the nested object
936
+ for (const nestedKey of keys) {
937
+
938
+ // Create a projection for this nested property
939
+ const nested$ = parentArray$.pipe(
940
+ map((arr) => {
941
+ const element = arr[index] as Record<string, unknown>;
942
+ const obj = element?.[key] as Record<string, unknown>;
943
+ return obj?.[nestedKey as string];
944
+ }),
945
+ countedDistinctUntilChanged(),
946
+ shareReplay(1)
947
+ );
948
+ nested$.subscribe();
949
+
950
+ children.set(nestedKey as string, {
951
+ $: nested$,
952
+ get: () => {
953
+ const arr = parentArray$.getValue();
954
+ const element = arr[index] as Record<string, unknown>;
955
+ const obj = element?.[key] as Record<string, unknown>;
956
+ return obj?.[nestedKey as string];
957
+ },
958
+ set: (v: unknown) => {
959
+ const arr = [...parentArray$.getValue()];
960
+ const element = { ...(arr[index] as object) } as Record<string, unknown>;
961
+ element[key] = { ...(element[key] as object), [nestedKey as string]: v };
962
+ arr[index] = element;
963
+ parentArray$.next(arr);
964
+ },
965
+ });
966
+ }
967
+
968
+ // Derive observable from children or parent
969
+ const obj$ = parentArray$.pipe(
970
+ map((arr) => (arr[index] as Record<string, unknown>)?.[key] as T),
971
+ countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
972
+ shareReplay(1)
973
+ );
974
+ obj$.subscribe();
975
+
976
+ return {
977
+ $: obj$,
978
+ children,
979
+ get: () => {
980
+ const arr = parentArray$.getValue();
981
+ return (arr[index] as Record<string, unknown>)?.[key] as T;
982
+ },
983
+ set: (v: T) => {
984
+ const arr = [...parentArray$.getValue()];
985
+ arr[index] = { ...(arr[index] as object), [key]: v };
986
+ parentArray$.next(arr);
987
+ },
988
+ };
989
+ }
990
+
991
+ // Nested array projection (array property inside array element)
992
+ function createNestedArrayProjection<T>(
993
+ parentArray$: BehaviorSubject<unknown[]>,
994
+ index: number,
995
+ key: string,
996
+ initialValue: T[]
997
+ ): NodeCore<T[]> {
998
+ const arr$ = parentArray$.pipe(
999
+ map((arr) => (arr[index] as Record<string, unknown>)?.[key] as T[]),
1000
+ countedDistinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
1001
+ shareReplay(1)
1002
+ );
1003
+ arr$.subscribe();
1004
+
1005
+ return {
1006
+ $: arr$,
1007
+ get: () => {
1008
+ const arr = parentArray$.getValue();
1009
+ return (arr[index] as Record<string, unknown>)?.[key] as T[];
1010
+ },
1011
+ set: (v: T[]) => {
1012
+ const arr = [...parentArray$.getValue()];
1013
+ arr[index] = { ...(arr[index] as object), [key]: v };
1014
+ parentArray$.next(arr);
1015
+ },
1016
+ };
1017
+ }
1018
+
1019
+ // Factory to create the right node type
1020
+ // When maybeNullable is true and value is null/undefined, creates a NullableNodeCore
1021
+ // that can later be upgraded to an object with children
1022
+ function createNodeForValue<T>(value: T, maybeNullable: boolean = false): NodeCore<T> {
1023
+ // Check for nullable marker (from nullable() helper)
1024
+ if (isNullableMarked(value)) {
1025
+ // Remove the marker before creating the node
1026
+ delete (value as Record<symbol, unknown>)[NULLABLE_MARKER];
1027
+ return createNullableObjectNode(value) as NodeCore<T>;
1028
+ }
1029
+
1030
+ if (value === null || value === undefined) {
1031
+ if (maybeNullable) {
1032
+ // Create nullable node that can be upgraded to object later
1033
+ return createNullableObjectNode(value) as NodeCore<T>;
1034
+ }
1035
+ return createLeafNode(value as Primitive) as NodeCore<T>;
1036
+ }
1037
+ if (typeof value !== "object") {
1038
+ return createLeafNode(value as Primitive) as NodeCore<T>;
1039
+ }
1040
+ if (Array.isArray(value)) {
1041
+ return createArrayNode(value) as unknown as NodeCore<T>;
1042
+ }
1043
+ return createObjectNode(value as object) as unknown as NodeCore<T>;
1044
+ }
1045
+
1046
+ // =============================================================================
1047
+ // Proxy Wrapper
1048
+ // =============================================================================
1049
+
1050
+ /**
1051
+ * Wraps a nullable object node with a proxy that:
1052
+ * - Returns undefined for child property access when value is null
1053
+ * - Creates/returns wrapped children when value is non-null
1054
+ * - Provides update() for batched updates
1055
+ */
1056
+ function wrapNullableWithProxy<T>(node: NullableNodeCore<T>): RxNullable<T> {
1057
+ // Create update function
1058
+ const update = (callback: (draft: object) => void): T => {
1059
+ node.lock();
1060
+ try {
1061
+ // Build a proxy for the children
1062
+ const childrenProxy = new Proxy({} as object, {
1063
+ get(_, prop: PropertyKey) {
1064
+ if (typeof prop === "string") {
1065
+ const child = node.getChild(prop);
1066
+ if (child) {
1067
+ return wrapWithProxy(child);
1068
+ }
1069
+ }
1070
+ return undefined;
1071
+ },
1072
+ });
1073
+ callback(childrenProxy);
1074
+ } finally {
1075
+ node.unlock();
1076
+ }
1077
+ return node.get();
1078
+ };
1079
+
1080
+ const proxy = new Proxy(node.$ as object, {
1081
+ get(target, prop: PropertyKey) {
1082
+ // Observable methods
1083
+ if (prop === "subscribe") return node.$.subscribe.bind(node.$);
1084
+ if (prop === "pipe") return node.$.pipe.bind(node.$);
1085
+ if (prop === "forEach") return (node.$ as any).forEach?.bind(node.$);
1086
+
1087
+ // Node methods
1088
+ if (prop === "get") return node.get;
1089
+ if (prop === "set") return node.set;
1090
+ if (prop === "update") return update;
1091
+ if (prop === "subscribeOnce") return node.subscribeOnce;
1092
+ if (prop === NODE) return node;
1093
+
1094
+ // Symbol.observable for RxJS interop
1095
+ if (prop === Symbol.observable || prop === "@@observable") {
1096
+ return () => node.$;
1097
+ }
1098
+
1099
+ // Child property access - uses getOrCreateChild for deep subscription support
1100
+ // This means store.user.age.subscribe() works even when user is null
1101
+ if (typeof prop === "string") {
1102
+ const child = node.getOrCreateChild(prop);
1103
+ return wrapWithProxy(child);
1104
+ }
1105
+
1106
+ // Fallback to observable properties
1107
+ if (prop in target) {
1108
+ const val = (target as Record<PropertyKey, unknown>)[prop];
1109
+ return typeof val === "function" ? val.bind(target) : val;
1110
+ }
1111
+
1112
+ return undefined;
1113
+ },
1114
+
1115
+ has(_, prop) {
1116
+ // When value is non-null and we have children, check if prop exists
1117
+ if (!node.isNull() && node.children && typeof prop === "string") {
1118
+ return node.children.has(prop);
1119
+ }
1120
+ return false;
1121
+ },
1122
+
1123
+ ownKeys() {
1124
+ if (!node.isNull() && node.children) {
1125
+ return Array.from(node.children.keys());
1126
+ }
1127
+ return [];
1128
+ },
1129
+
1130
+ getOwnPropertyDescriptor(_, prop) {
1131
+ if (!node.isNull() && node.children && typeof prop === "string" && node.children.has(prop)) {
1132
+ return { enumerable: true, configurable: true };
1133
+ }
1134
+ return undefined;
1135
+ },
1136
+ });
1137
+
1138
+ return proxy as unknown as RxNullable<T>;
1139
+ }
1140
+
1141
+ function wrapWithProxy<T>(node: NodeCore<T>): RxNodeFor<T> {
1142
+ // Check for nullable node first (before checking value, since value might be null)
1143
+ if (isNullableNode(node)) {
1144
+ return wrapNullableWithProxy(node) as RxNodeFor<T>;
1145
+ }
1146
+
1147
+ const value = node.get();
1148
+
1149
+ // Primitive - just attach methods to observable
1150
+ if (value === null || typeof value !== "object") {
1151
+ return Object.assign(node.$, {
1152
+ get: node.get,
1153
+ set: node.set,
1154
+ subscribe: node.$.subscribe.bind(node.$),
1155
+ pipe: node.$.pipe.bind(node.$),
1156
+ subscribeOnce: node.subscribeOnce,
1157
+ [NODE]: node,
1158
+ }) as RxNodeFor<T>;
1159
+ }
1160
+
1161
+ // Array
1162
+ if (Array.isArray(value)) {
1163
+ const arrayNode = node as unknown as NodeCore<unknown[]> & {
1164
+ at(index: number): NodeCore<unknown> | undefined;
1165
+ childCache: Map<number, NodeCore<unknown>>;
1166
+ length$: Observable<number> & { get(): number };
1167
+ push(...items: unknown[]): number;
1168
+ pop(): unknown | undefined;
1169
+ mapItems<U>(fn: (item: unknown, index: number) => U): U[];
1170
+ filterItems(fn: (item: unknown, index: number) => boolean): unknown[];
1171
+ lock(): void;
1172
+ unlock(): void;
1173
+ };
1174
+
1175
+ // Create the wrapped result first so we can reference it in update
1176
+ const wrapped = Object.assign(node.$, {
1177
+ get: node.get,
1178
+ set: node.set,
1179
+ subscribe: node.$.subscribe.bind(node.$),
1180
+ pipe: node.$.pipe.bind(node.$),
1181
+ subscribeOnce: node.subscribeOnce,
1182
+ at: (index: number) => {
1183
+ const child = arrayNode.at(index);
1184
+ if (!child) return undefined;
1185
+ return wrapWithProxy(child);
1186
+ },
1187
+ length: arrayNode.length$,
1188
+ push: arrayNode.push,
1189
+ pop: arrayNode.pop,
1190
+ map: arrayNode.mapItems,
1191
+ filter: arrayNode.filterItems,
1192
+ update: (callback: (draft: unknown[]) => void): unknown[] => {
1193
+ arrayNode.lock(); // Lock - suppress emissions
1194
+ try {
1195
+ callback(wrapped as unknown as unknown[]); // Pass wrapped array so user can use .at(), .push(), etc.
1196
+ } finally {
1197
+ arrayNode.unlock(); // Unlock - emit final state
1198
+ }
1199
+ return node.get() as unknown[];
1200
+ },
1201
+ [NODE]: node,
1202
+ });
1203
+
1204
+ return wrapped as unknown as RxNodeFor<T>;
1205
+ }
1206
+
1207
+ // Object - use Proxy for property access
1208
+ const objectNode = node as unknown as NodeCore<object> & {
1209
+ children?: Map<string, NodeCore<unknown>>;
1210
+ lock?(): void;
1211
+ unlock?(): void;
1212
+ };
1213
+
1214
+ // Create update function that has access to the proxy (defined after proxy creation)
1215
+ let updateFn: ((callback: (draft: object) => void) => object) | undefined;
1216
+
1217
+ const proxy = new Proxy(node.$ as object, {
1218
+ get(target, prop: PropertyKey) {
1219
+ // Observable methods
1220
+ if (prop === "subscribe") return node.$.subscribe.bind(node.$);
1221
+ if (prop === "pipe") return node.$.pipe.bind(node.$);
1222
+ if (prop === "forEach") return (node.$ as any).forEach?.bind(node.$);
1223
+
1224
+ // Node methods
1225
+ if (prop === "get") return node.get;
1226
+ if (prop === "set") return node.set;
1227
+ if (prop === "update") return updateFn;
1228
+ if (prop === "subscribeOnce") return node.subscribeOnce;
1229
+ if (prop === NODE) return node;
1230
+
1231
+ // Symbol.observable for RxJS interop
1232
+ if (prop === Symbol.observable || prop === "@@observable") {
1233
+ return () => node.$;
1234
+ }
1235
+
1236
+ // Child property access
1237
+ if (objectNode.children && typeof prop === "string") {
1238
+ const child = objectNode.children.get(prop);
1239
+ if (child) {
1240
+ return wrapWithProxy(child);
1241
+ }
1242
+ }
1243
+
1244
+ // Fallback to observable properties
1245
+ if (prop in target) {
1246
+ const val = (target as Record<PropertyKey, unknown>)[prop];
1247
+ return typeof val === "function" ? val.bind(target) : val;
1248
+ }
1249
+
1250
+ return undefined;
1251
+ },
1252
+
1253
+ has(target, prop) {
1254
+ if (objectNode.children && typeof prop === "string") {
1255
+ return objectNode.children.has(prop);
1256
+ }
1257
+ return prop in target;
1258
+ },
1259
+
1260
+ ownKeys() {
1261
+ if (objectNode.children) {
1262
+ return Array.from(objectNode.children.keys());
1263
+ }
1264
+ return [];
1265
+ },
1266
+
1267
+ getOwnPropertyDescriptor(target, prop) {
1268
+ if (objectNode.children && typeof prop === "string" && objectNode.children.has(prop)) {
1269
+ return { enumerable: true, configurable: true };
1270
+ }
1271
+ return undefined;
1272
+ },
1273
+ });
1274
+
1275
+ // Now define update function with access to proxy
1276
+ if (objectNode.lock && objectNode.unlock) {
1277
+ updateFn = (callback: (draft: object) => void): object => {
1278
+ objectNode.lock!(); // Lock - suppress emissions
1279
+ try {
1280
+ callback(proxy as object); // Pass the proxy so user can call .set() on children
1281
+ } finally {
1282
+ objectNode.unlock!(); // Unlock - emit final state
1283
+ }
1284
+ return node.get() as object;
1285
+ };
1286
+ }
1287
+
1288
+ return proxy as RxNodeFor<T>;
1289
+ }
1290
+
1291
+ // =============================================================================
1292
+ // Public API
1293
+ // =============================================================================
1294
+
1295
+ export function state<T extends object>(initialState: T): RxState<T> {
1296
+ const node = createObjectNode(initialState);
1297
+ return wrapWithProxy(node as NodeCore<T>) as RxState<T>;
1298
+ }
1299
+
1300
+ // Symbol to mark a value as nullable
1301
+ const NULLABLE_MARKER = Symbol("nullable");
1302
+
1303
+
1304
+ /**
1305
+ * Marks a value as nullable, allowing it to transition between null and object.
1306
+ * Use this when you want to start with an object value but later set it to null.
1307
+ *
1308
+ * @example
1309
+ * const store = state({
1310
+ * // Can start with object and later be set to null
1311
+ * user: nullable({ name: "Alice", age: 30 }),
1312
+ * // Can start with null and later be set to object
1313
+ * profile: nullable<{ bio: string }>(null),
1314
+ * });
1315
+ *
1316
+ * // Use ?. on the nullable property, then access children directly
1317
+ * store.user?.set(null); // Works!
1318
+ * store.user?.set({ name: "Bob", age: 25 }); // Works!
1319
+ * store.user?.name.set("Charlie"); // After ?. on user, children are directly accessible
1320
+ */
1321
+ export function nullable<T extends object>(value: T | null): T | null {
1322
+ if (value === null) {
1323
+ return null;
1324
+ }
1325
+ // Mark the object so createNodeForValue knows to use NullableNodeCore
1326
+ return Object.assign(value, { [NULLABLE_MARKER]: true }) as T | null;
1327
+ }
1328
+
1329
+ // Check if a value was marked as nullable
1330
+ function isNullableMarked<T>(value: T): boolean {
1331
+ return value !== null && typeof value === "object" && NULLABLE_MARKER in value;
1332
+ }