@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.
- package/dist/deepstate.d.ts +189 -0
- package/dist/deepstate.d.ts.map +1 -0
- package/dist/deepstate.js +881 -0
- package/dist/deepstate.js.map +1 -0
- package/dist/helpers.d.ts +61 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +48 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
- package/src/deepstate.ts +1332 -0
- package/src/helpers.ts +138 -0
- package/src/index.ts +15 -0
package/src/deepstate.ts
ADDED
|
@@ -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
|
+
}
|