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