jotai-state-tree 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/src/tree.ts ADDED
@@ -0,0 +1,1275 @@
1
+ /**
2
+ * Tree node management system
3
+ * Handles tree structure, parent/child relationships, and node lifecycle
4
+ *
5
+ * MEMORY MANAGEMENT:
6
+ * - Uses WeakRef for node registry to allow garbage collection
7
+ * - Uses FinalizationRegistry for automatic cleanup of abandoned nodes
8
+ * - Uses WeakMap where possible to avoid preventing GC
9
+ * - Properly cleans up all registries on node destruction
10
+ */
11
+
12
+ import { atom, createStore, type WritableAtom } from "jotai";
13
+ import type {
14
+ IStateTreeNode,
15
+ IType,
16
+ IAnyType,
17
+ IAnyModelType,
18
+ IJsonPatch,
19
+ IReversibleJsonPatch,
20
+ IDisposer,
21
+ } from "./types";
22
+
23
+ // Re-export IDisposer for convenience
24
+ export type { IDisposer };
25
+
26
+ // ============================================================================
27
+ // Global Store & Registry
28
+ // ============================================================================
29
+
30
+ /** Global Jotai store instance */
31
+ let globalStore = createStore();
32
+
33
+ /** Get the global store */
34
+ export function getGlobalStore() {
35
+ return globalStore;
36
+ }
37
+
38
+ /** Set a custom global store (useful for testing) */
39
+ export function setGlobalStore(store: ReturnType<typeof createStore>) {
40
+ globalStore = store;
41
+ }
42
+
43
+ /** Reset the global store (useful for testing) */
44
+ export function resetGlobalStore() {
45
+ globalStore = createStore();
46
+ }
47
+
48
+ // ============================================================================
49
+ // Node Registry with Weak References
50
+ // ============================================================================
51
+
52
+ interface NodeEntry {
53
+ node: WeakRef<StateTreeNode>;
54
+ instance: WeakRef<object> | null;
55
+ }
56
+
57
+ /**
58
+ * Registry mapping node IDs to their entries using WeakRef
59
+ * This allows nodes to be garbage collected when no longer referenced
60
+ */
61
+ const nodeRegistry = new Map<string, NodeEntry>();
62
+
63
+ /**
64
+ * FinalizationRegistry for automatic cleanup when nodes are garbage collected
65
+ * This ensures the nodeRegistry doesn't accumulate stale entries
66
+ */
67
+ const nodeFinalizationRegistry = new FinalizationRegistry((nodeId: string) => {
68
+ nodeRegistry.delete(nodeId);
69
+ });
70
+
71
+ /**
72
+ * Registry for identifier lookups (type -> identifier -> WeakRef<node>)
73
+ * Uses WeakRef to allow garbage collection of nodes
74
+ */
75
+ const identifierRegistry = new Map<
76
+ string,
77
+ Map<string | number, WeakRef<StateTreeNode>>
78
+ >();
79
+
80
+ /**
81
+ * FinalizationRegistry for identifier cleanup
82
+ */
83
+ const identifierFinalizationRegistry = new FinalizationRegistry(
84
+ (info: { typeName: string; identifier: string | number }) => {
85
+ const typeMap = identifierRegistry.get(info.typeName);
86
+ if (typeMap) {
87
+ typeMap.delete(info.identifier);
88
+ // Clean up empty type maps
89
+ if (typeMap.size === 0) {
90
+ identifierRegistry.delete(info.typeName);
91
+ }
92
+ }
93
+ },
94
+ );
95
+
96
+ /** Counter for generating unique node IDs */
97
+ let nodeIdCounter = 0;
98
+
99
+ function generateNodeId(): string {
100
+ return `node_${++nodeIdCounter}_${Date.now().toString(36)}`;
101
+ }
102
+
103
+ // ============================================================================
104
+ // Lifecycle Change Listeners (for useIsAlive and other subscribers)
105
+ // ============================================================================
106
+
107
+ /** WeakMap to store lifecycle listeners per node - allows GC of nodes */
108
+ const lifecycleListeners = new WeakMap<
109
+ StateTreeNode,
110
+ Set<(isAlive: boolean) => void>
111
+ >();
112
+
113
+ /** Subscribe to lifecycle changes of a node */
114
+ export function onLifecycleChange(
115
+ node: StateTreeNode,
116
+ listener: (isAlive: boolean) => void,
117
+ ): IDisposer {
118
+ let listeners = lifecycleListeners.get(node);
119
+ if (!listeners) {
120
+ listeners = new Set();
121
+ lifecycleListeners.set(node, listeners);
122
+ }
123
+ listeners.add(listener);
124
+ return () => {
125
+ listeners?.delete(listener);
126
+ };
127
+ }
128
+
129
+ /** Notify lifecycle listeners */
130
+ function notifyLifecycleChange(node: StateTreeNode, isAlive: boolean) {
131
+ const listeners = lifecycleListeners.get(node);
132
+ if (listeners) {
133
+ listeners.forEach((listener) => listener(isAlive));
134
+ }
135
+ }
136
+
137
+ // ============================================================================
138
+ // State Tree Node Implementation
139
+ // ============================================================================
140
+
141
+ export class StateTreeNode implements IStateTreeNode {
142
+ readonly $id: string;
143
+ readonly $type: IAnyType;
144
+ $parent: StateTreeNode | null = null;
145
+ $path: string = "";
146
+ $env: unknown;
147
+ $isAlive: boolean = true;
148
+
149
+ /** Child nodes - uses Map but children are explicitly destroyed */
150
+ private children = new Map<string, StateTreeNode>();
151
+
152
+ /** Atom storing the raw value/snapshot */
153
+ valueAtom: WritableAtom<unknown, [unknown], void>;
154
+
155
+ /** Snapshot listeners */
156
+ private snapshotListeners = new Set<(snapshot: unknown) => void>();
157
+
158
+ /** Patch listeners */
159
+ private patchListeners = new Set<
160
+ (patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void
161
+ >();
162
+
163
+ /** Volatile state (non-serialized) */
164
+ volatileState: Record<string, unknown> = {};
165
+
166
+ /** Pre/post process snapshot functions */
167
+ preProcessor?: (snapshot: unknown) => unknown;
168
+ postProcessor?: (snapshot: unknown) => unknown;
169
+
170
+ /** Identifier value if this node has one */
171
+ identifierValue?: string | number;
172
+
173
+ /** Type name for identifier registry */
174
+ identifierTypeName?: string;
175
+
176
+ constructor(
177
+ type: IAnyType,
178
+ initialValue: unknown,
179
+ env?: unknown,
180
+ parent?: StateTreeNode,
181
+ pathSegment?: string,
182
+ ) {
183
+ this.$id = generateNodeId();
184
+ this.$type = type;
185
+ this.$env = env ?? parent?.$env;
186
+ this.$parent = parent ?? null;
187
+ this.$path = parent ? `${parent.$path}/${pathSegment}` : "";
188
+
189
+ // Create the value atom
190
+ this.valueAtom = atom(initialValue);
191
+
192
+ // Register this node with WeakRef
193
+ nodeRegistry.set(this.$id, { node: new WeakRef(this), instance: null });
194
+
195
+ // Register for automatic cleanup on GC
196
+ nodeFinalizationRegistry.register(this, this.$id, this);
197
+ }
198
+
199
+ /** Set the instance reference */
200
+ setInstance(instance: unknown) {
201
+ const entry = nodeRegistry.get(this.$id);
202
+ if (entry && instance && typeof instance === "object") {
203
+ entry.instance = new WeakRef(instance as object);
204
+ }
205
+ }
206
+
207
+ /** Get the instance */
208
+ getInstance(): unknown {
209
+ const entry = nodeRegistry.get(this.$id);
210
+ return entry?.instance?.deref() ?? null;
211
+ }
212
+
213
+ /** Get current value from atom */
214
+ getValue(): unknown {
215
+ return globalStore.get(this.valueAtom);
216
+ }
217
+
218
+ /** Set value on atom */
219
+ setValue(value: unknown) {
220
+ if (!this.$isAlive) {
221
+ throw new Error(
222
+ `[jotai-state-tree] Cannot modify a node that is no longer part of the state tree. ` +
223
+ `(Node type: '${this.$type.name}', Path: '${this.$path}')`,
224
+ );
225
+ }
226
+
227
+ const oldValue = this.getValue();
228
+ globalStore.set(this.valueAtom, value);
229
+
230
+ // Notify patch listeners
231
+ this.notifyPatch(
232
+ { op: "replace", path: this.$path, value },
233
+ { op: "replace", path: this.$path, value: oldValue, oldValue },
234
+ );
235
+
236
+ // Notify snapshot listeners (bubble up to root)
237
+ this.notifySnapshotChange();
238
+ }
239
+
240
+ /** Add a child node */
241
+ addChild(key: string, child: StateTreeNode) {
242
+ child.$parent = this;
243
+ const newPath = `${this.$path}/${key}`;
244
+ this.updatePathRecursively(child, newPath);
245
+ child.$env = child.$env ?? this.$env;
246
+ this.children.set(key, child);
247
+ }
248
+
249
+ /** Recursively update the path of a node and all its children */
250
+ private updatePathRecursively(node: StateTreeNode, newPath: string) {
251
+ node.$path = newPath;
252
+
253
+ // Update all children's paths
254
+ for (const [childKey, childNode] of node.children) {
255
+ const childNewPath = `${newPath}/${childKey}`;
256
+ this.updatePathRecursively(childNode, childNewPath);
257
+ }
258
+ }
259
+
260
+ /** Remove a child node */
261
+ removeChild(key: string) {
262
+ const child = this.children.get(key);
263
+ if (child) {
264
+ child.destroy();
265
+ this.children.delete(key);
266
+ }
267
+ }
268
+
269
+ /** Get a child node */
270
+ getChild(key: string): StateTreeNode | undefined {
271
+ return this.children.get(key);
272
+ }
273
+
274
+ /** Get all children */
275
+ getChildren(): Map<string, StateTreeNode> {
276
+ return this.children;
277
+ }
278
+
279
+ /** Register identifier */
280
+ registerIdentifier(typeName: string, identifier: string | number) {
281
+ this.identifierTypeName = typeName;
282
+ this.identifierValue = identifier;
283
+
284
+ let typeMap = identifierRegistry.get(typeName);
285
+ if (!typeMap) {
286
+ typeMap = new Map();
287
+ identifierRegistry.set(typeName, typeMap);
288
+ }
289
+ typeMap.set(identifier, new WeakRef(this));
290
+
291
+ // Register for automatic cleanup on GC
292
+ identifierFinalizationRegistry.register(
293
+ this,
294
+ { typeName, identifier },
295
+ this,
296
+ );
297
+ }
298
+
299
+ /** Unregister identifier */
300
+ unregisterIdentifier() {
301
+ if (
302
+ this.identifierTypeName !== undefined &&
303
+ this.identifierValue !== undefined
304
+ ) {
305
+ const typeMap = identifierRegistry.get(this.identifierTypeName);
306
+ if (typeMap) {
307
+ typeMap.delete(this.identifierValue);
308
+ // Clean up empty type maps to prevent accumulation
309
+ if (typeMap.size === 0) {
310
+ identifierRegistry.delete(this.identifierTypeName);
311
+ }
312
+ }
313
+ // Unregister from finalization registry
314
+ identifierFinalizationRegistry.unregister(this);
315
+ }
316
+ }
317
+
318
+ /** Subscribe to snapshot changes */
319
+ onSnapshot(listener: (snapshot: unknown) => void): IDisposer {
320
+ this.snapshotListeners.add(listener);
321
+ return () => {
322
+ this.snapshotListeners.delete(listener);
323
+ };
324
+ }
325
+
326
+ /** Subscribe to patches */
327
+ onPatch(
328
+ listener: (patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void,
329
+ ): IDisposer {
330
+ this.patchListeners.add(listener);
331
+ return () => {
332
+ this.patchListeners.delete(listener);
333
+ };
334
+ }
335
+
336
+ /** Notify patch listeners */
337
+ private notifyPatch(patch: IJsonPatch, reversePatch: IReversibleJsonPatch) {
338
+ this.patchListeners.forEach((listener) => listener(patch, reversePatch));
339
+ // Bubble up to parent
340
+ if (this.$parent) {
341
+ this.$parent.notifyPatch(patch, reversePatch);
342
+ }
343
+ }
344
+
345
+ /** Notify snapshot listeners */
346
+ private notifySnapshotChange() {
347
+ // Get the root and notify its listeners
348
+ const root = this.getRoot();
349
+ const snapshot = getSnapshotFromNode(root);
350
+ root.snapshotListeners.forEach((listener) => listener(snapshot));
351
+ }
352
+
353
+ /** Notify about a property change (for use by model proxy) */
354
+ notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown) {
355
+ const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
356
+ this.notifyPatch(
357
+ { op: "replace", path, value: newValue },
358
+ { op: "replace", path, value: oldValue, oldValue },
359
+ );
360
+ this.notifySnapshotChange();
361
+ }
362
+
363
+ /** Get root node */
364
+ getRoot(): StateTreeNode {
365
+ let node: StateTreeNode = this;
366
+ while (node.$parent) {
367
+ node = node.$parent;
368
+ }
369
+ return node;
370
+ }
371
+
372
+ /** Destroy this node and all children */
373
+ destroy() {
374
+ if (!this.$isAlive) return;
375
+
376
+ // Destroy children first
377
+ this.children.forEach((child) => child.destroy());
378
+ this.children.clear();
379
+
380
+ // Unregister identifier
381
+ this.unregisterIdentifier();
382
+
383
+ // Mark as dead
384
+ this.$isAlive = false;
385
+
386
+ // Notify lifecycle listeners
387
+ notifyLifecycleChange(this, false);
388
+
389
+ // Remove from node registry
390
+ nodeRegistry.delete(this.$id);
391
+
392
+ // Unregister from finalization registry (already destroyed, don't need GC cleanup)
393
+ nodeFinalizationRegistry.unregister(this);
394
+
395
+ // Clear listeners
396
+ this.snapshotListeners.clear();
397
+ this.patchListeners.clear();
398
+ }
399
+
400
+ /** Detach from parent */
401
+ detach() {
402
+ if (this.$parent) {
403
+ // Find our key in parent's children
404
+ for (const [key, child] of this.$parent.children) {
405
+ if (child === this) {
406
+ this.$parent.children.delete(key);
407
+ break;
408
+ }
409
+ }
410
+ this.$parent = null;
411
+ this.$path = "";
412
+ }
413
+ }
414
+ }
415
+
416
+ // ============================================================================
417
+ // Node Utilities
418
+ // ============================================================================
419
+
420
+ /** Symbol to access the tree node from an instance */
421
+ export const $treenode = Symbol.for("jotai-state-tree-node");
422
+
423
+ /** Get the tree node from an instance */
424
+ export function getStateTreeNode(instance: unknown): StateTreeNode {
425
+ if (instance && typeof instance === "object" && $treenode in instance) {
426
+ return (instance as Record<typeof $treenode, StateTreeNode>)[$treenode];
427
+ }
428
+ throw new Error("[jotai-state-tree] Value is not a state tree node");
429
+ }
430
+
431
+ /** Check if value has a tree node */
432
+ export function hasStateTreeNode(instance: unknown): boolean {
433
+ return (
434
+ instance !== null && typeof instance === "object" && $treenode in instance
435
+ );
436
+ }
437
+
438
+ /** Get snapshot from a node */
439
+ export function getSnapshotFromNode(node: StateTreeNode): unknown {
440
+ const type = node.$type;
441
+ const value = node.getValue();
442
+
443
+ // Handle based on type kind
444
+ if (type._kind === "model") {
445
+ const snapshot: Record<string, unknown> = {};
446
+ const children = node.getChildren();
447
+
448
+ for (const [key, childNode] of children) {
449
+ snapshot[key] = getSnapshotFromNode(childNode);
450
+ }
451
+
452
+ // Apply post processor if exists
453
+ if (node.postProcessor) {
454
+ return node.postProcessor(snapshot);
455
+ }
456
+
457
+ return snapshot;
458
+ }
459
+
460
+ if (type._kind === "array") {
461
+ const arr = value as unknown[];
462
+ return arr.map((_, index) => {
463
+ const childNode = node.getChild(String(index));
464
+ return childNode ? getSnapshotFromNode(childNode) : arr[index];
465
+ });
466
+ }
467
+
468
+ if (type._kind === "map") {
469
+ const snapshot: Record<string, unknown> = {};
470
+ const children = node.getChildren();
471
+ for (const [key, childNode] of children) {
472
+ snapshot[key] = getSnapshotFromNode(childNode);
473
+ }
474
+ return snapshot;
475
+ }
476
+
477
+ if (type._kind === "reference") {
478
+ // Return the identifier, not the resolved value
479
+ return node.identifierValue ?? value;
480
+ }
481
+
482
+ // For primitives and frozen, return the value directly
483
+ return value;
484
+ }
485
+
486
+ /** Apply snapshot to a node */
487
+ export function applySnapshotToNode(node: StateTreeNode, snapshot: unknown) {
488
+ if (!node.$isAlive) {
489
+ throw new Error("[jotai-state-tree] Cannot apply snapshot to a dead node");
490
+ }
491
+
492
+ const type = node.$type;
493
+
494
+ // Apply pre processor if exists
495
+ if (node.preProcessor) {
496
+ snapshot = node.preProcessor(snapshot);
497
+ }
498
+
499
+ if (
500
+ type._kind === "model" &&
501
+ typeof snapshot === "object" &&
502
+ snapshot !== null
503
+ ) {
504
+ const snapshotObj = snapshot as Record<string, unknown>;
505
+ const children = node.getChildren();
506
+
507
+ for (const [key, childNode] of children) {
508
+ if (key in snapshotObj) {
509
+ applySnapshotToNode(childNode, snapshotObj[key]);
510
+ }
511
+ }
512
+ } else if (type._kind === "array" && Array.isArray(snapshot)) {
513
+ // For arrays, we need to reconcile
514
+ node.setValue(snapshot);
515
+ } else if (
516
+ type._kind === "map" &&
517
+ typeof snapshot === "object" &&
518
+ snapshot !== null
519
+ ) {
520
+ // For maps, replace all entries
521
+ node.setValue(snapshot);
522
+ } else {
523
+ // For primitives
524
+ node.setValue(snapshot);
525
+ }
526
+ }
527
+
528
+ /** Look up a node by identifier */
529
+ export function resolveIdentifier(
530
+ typeName: string,
531
+ identifier: string | number,
532
+ ): StateTreeNode | undefined {
533
+ const weakRef = identifierRegistry.get(typeName)?.get(identifier);
534
+ return weakRef?.deref();
535
+ }
536
+
537
+ /** Get all nodes of a type */
538
+ export function getNodesOfType(typeName: string): StateTreeNode[] {
539
+ const typeMap = identifierRegistry.get(typeName);
540
+ if (!typeMap) return [];
541
+
542
+ const nodes: StateTreeNode[] = [];
543
+ for (const weakRef of typeMap.values()) {
544
+ const node = weakRef.deref();
545
+ if (node) {
546
+ nodes.push(node);
547
+ }
548
+ }
549
+ return nodes;
550
+ }
551
+
552
+ // ============================================================================
553
+ // Registry Statistics (for testing and debugging)
554
+ // ============================================================================
555
+
556
+ /** Get statistics about the registries - useful for debugging memory issues */
557
+ export function getRegistryStats(): {
558
+ nodeRegistrySize: number;
559
+ identifierRegistrySize: number;
560
+ identifierTypeCount: number;
561
+ liveNodeCount: number;
562
+ staleNodeCount: number;
563
+ } {
564
+ let liveNodeCount = 0;
565
+ let staleNodeCount = 0;
566
+
567
+ for (const entry of nodeRegistry.values()) {
568
+ const node = entry.node.deref();
569
+ // A node is "live" if it exists AND $isAlive is true
570
+ if (node && node.$isAlive) {
571
+ liveNodeCount++;
572
+ } else {
573
+ staleNodeCount++;
574
+ }
575
+ }
576
+
577
+ let identifierCount = 0;
578
+ for (const typeMap of identifierRegistry.values()) {
579
+ // Only count identifiers that point to live nodes
580
+ for (const weakRef of typeMap.values()) {
581
+ const node = weakRef.deref();
582
+ if (node && node.$isAlive) {
583
+ identifierCount++;
584
+ }
585
+ }
586
+ }
587
+
588
+ return {
589
+ nodeRegistrySize: nodeRegistry.size,
590
+ identifierRegistrySize: identifierCount,
591
+ identifierTypeCount: identifierRegistry.size,
592
+ liveNodeCount,
593
+ staleNodeCount,
594
+ };
595
+ }
596
+
597
+ /** Clean up stale entries from registries - call periodically if needed */
598
+ export function cleanupStaleEntries(): number {
599
+ let cleaned = 0;
600
+
601
+ // Clean stale node entries
602
+ for (const [id, entry] of nodeRegistry.entries()) {
603
+ if (!entry.node.deref()) {
604
+ nodeRegistry.delete(id);
605
+ cleaned++;
606
+ }
607
+ }
608
+
609
+ // Clean stale identifier entries
610
+ for (const [typeName, typeMap] of identifierRegistry.entries()) {
611
+ for (const [identifier, weakRef] of typeMap.entries()) {
612
+ if (!weakRef.deref()) {
613
+ typeMap.delete(identifier);
614
+ cleaned++;
615
+ }
616
+ }
617
+ if (typeMap.size === 0) {
618
+ identifierRegistry.delete(typeName);
619
+ }
620
+ }
621
+
622
+ return cleaned;
623
+ }
624
+
625
+ /** Clear all registries - useful for testing */
626
+ export function clearAllRegistries(): void {
627
+ // First, mark all nodes as dead before clearing
628
+ for (const entry of nodeRegistry.values()) {
629
+ const node = entry.node.deref();
630
+ if (node) {
631
+ node.$isAlive = false;
632
+ }
633
+ }
634
+ nodeRegistry.clear();
635
+ identifierRegistry.clear();
636
+ nodeIdCounter = 0;
637
+ }
638
+
639
+ // ============================================================================
640
+ // Tree Navigation Functions
641
+ // ============================================================================
642
+
643
+ /** Get the root of the tree */
644
+ export function getRoot<T>(target: T): T {
645
+ const node = getStateTreeNode(target);
646
+ const rootNode = node.getRoot();
647
+ return rootNode.getInstance() as T;
648
+ }
649
+
650
+ /** Get the parent of a node */
651
+ export function getParent<T = unknown>(target: unknown, depth: number = 1): T {
652
+ let node = getStateTreeNode(target);
653
+ for (let i = 0; i < depth; i++) {
654
+ if (!node.$parent) {
655
+ throw new Error("[jotai-state-tree] Cannot get parent of root node");
656
+ }
657
+ node = node.$parent;
658
+ }
659
+ return node.getInstance() as T;
660
+ }
661
+
662
+ /** Try to get the parent, returns undefined if at root */
663
+ export function tryGetParent<T = unknown>(
664
+ target: unknown,
665
+ depth: number = 1,
666
+ ): T | undefined {
667
+ try {
668
+ return getParent<T>(target, depth);
669
+ } catch {
670
+ return undefined;
671
+ }
672
+ }
673
+
674
+ /** Check if a node has a parent */
675
+ export function hasParent(target: unknown, depth: number = 1): boolean {
676
+ let node = getStateTreeNode(target);
677
+ for (let i = 0; i < depth; i++) {
678
+ if (!node.$parent) return false;
679
+ node = node.$parent;
680
+ }
681
+ return true;
682
+ }
683
+
684
+ /** Get parent of specific type */
685
+ export function getParentOfType<T extends IAnyModelType>(
686
+ target: unknown,
687
+ type: T,
688
+ ): T extends IType<unknown, unknown, infer I> ? I : never {
689
+ let node: StateTreeNode | null = getStateTreeNode(target).$parent;
690
+
691
+ while (node) {
692
+ if (node.$type === type || node.$type.name === type.name) {
693
+ return node.getInstance() as T extends IType<unknown, unknown, infer I>
694
+ ? I
695
+ : never;
696
+ }
697
+ node = node.$parent;
698
+ }
699
+
700
+ throw new Error(`[jotai-state-tree] No parent of type '${type.name}' found`);
701
+ }
702
+
703
+ /** Get the path of a node */
704
+ export function getPath(target: unknown): string {
705
+ return getStateTreeNode(target).$path;
706
+ }
707
+
708
+ /** Get path parts as array */
709
+ export function getPathParts(target: unknown): string[] {
710
+ const path = getPath(target);
711
+ return path ? path.split("/").filter(Boolean) : [];
712
+ }
713
+
714
+ /** Get the environment */
715
+ export function getEnv<E = unknown>(target: unknown): E {
716
+ return getStateTreeNode(target).$env as E;
717
+ }
718
+
719
+ /** Check if node is alive */
720
+ export function isAlive(target: unknown): boolean {
721
+ try {
722
+ return getStateTreeNode(target).$isAlive;
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+
728
+ /** Check if node is root */
729
+ export function isRoot(target: unknown): boolean {
730
+ return getStateTreeNode(target).$parent === null;
731
+ }
732
+
733
+ /** Get the type of a node */
734
+ export function getType(target: unknown): IAnyType {
735
+ return getStateTreeNode(target).$type;
736
+ }
737
+
738
+ /** Check if value is a state tree node */
739
+ export function isStateTreeNode(value: unknown): boolean {
740
+ return hasStateTreeNode(value);
741
+ }
742
+
743
+ /** Get identifier of a node */
744
+ export function getIdentifier(target: unknown): string | number | null {
745
+ const node = getStateTreeNode(target);
746
+ return node.identifierValue ?? null;
747
+ }
748
+
749
+ /** Destroy a node */
750
+ export function destroy(target: unknown): void {
751
+ const node = getStateTreeNode(target);
752
+ node.destroy();
753
+ }
754
+
755
+ /** Detach a node from its parent */
756
+ export function detach<T>(target: T): T {
757
+ const node = getStateTreeNode(target);
758
+ node.detach();
759
+ return target;
760
+ }
761
+
762
+ /** Clone a node */
763
+ export function clone<T>(target: T, keepEnvironment: boolean = true): T {
764
+ const node = getStateTreeNode(target);
765
+ const snapshot = getSnapshotFromNode(node);
766
+ const type = node.$type;
767
+ return type.create(snapshot, keepEnvironment ? node.$env : undefined) as T;
768
+ }
769
+
770
+ // ============================================================================
771
+ // Snapshot & Patch Functions
772
+ // ============================================================================
773
+
774
+ /** Get snapshot from an instance */
775
+ export function getSnapshot<S>(target: unknown): S {
776
+ const node = getStateTreeNode(target);
777
+ return getSnapshotFromNode(node) as S;
778
+ }
779
+
780
+ /** Apply snapshot to an instance */
781
+ export function applySnapshot<S>(target: unknown, snapshot: S): void {
782
+ const node = getStateTreeNode(target);
783
+ applySnapshotToNode(node, snapshot);
784
+ }
785
+
786
+ /** Subscribe to snapshots */
787
+ export function onSnapshot<S>(
788
+ target: unknown,
789
+ listener: (snapshot: S) => void,
790
+ ): IDisposer {
791
+ const node = getStateTreeNode(target);
792
+ return node.onSnapshot(listener as (snapshot: unknown) => void);
793
+ }
794
+
795
+ /** Subscribe to patches */
796
+ export function onPatch(
797
+ target: unknown,
798
+ listener: (patch: IJsonPatch, reversePatch: IReversibleJsonPatch) => void,
799
+ ): IDisposer {
800
+ const node = getStateTreeNode(target);
801
+ return node.onPatch(listener);
802
+ }
803
+
804
+ /** Apply a single patch */
805
+ export function applyPatch(
806
+ target: unknown,
807
+ patch: IJsonPatch | IJsonPatch[],
808
+ ): void {
809
+ const patches = Array.isArray(patch) ? patch : [patch];
810
+ const rootNode = getStateTreeNode(target).getRoot();
811
+
812
+ for (const p of patches) {
813
+ applyPatchToNode(rootNode, p);
814
+ }
815
+ }
816
+
817
+ function applyPatchToNode(rootNode: StateTreeNode, patch: IJsonPatch): void {
818
+ const pathParts = patch.path.split("/").filter(Boolean);
819
+ let node = rootNode;
820
+
821
+ // Navigate to the target node
822
+ for (let i = 0; i < pathParts.length - 1; i++) {
823
+ const childNode = node.getChild(pathParts[i]);
824
+ if (!childNode) {
825
+ throw new Error(`[jotai-state-tree] Invalid patch path: ${patch.path}`);
826
+ }
827
+ node = childNode;
828
+ }
829
+
830
+ const key = pathParts[pathParts.length - 1];
831
+
832
+ switch (patch.op) {
833
+ case "replace": {
834
+ const childNode = node.getChild(key);
835
+ if (childNode) {
836
+ applySnapshotToNode(childNode, patch.value);
837
+ } else {
838
+ // Direct value set for primitives
839
+ const currentValue = node.getValue() as Record<string, unknown>;
840
+ currentValue[key] = patch.value;
841
+ node.setValue(currentValue);
842
+ }
843
+ break;
844
+ }
845
+ case "add": {
846
+ const currentValue = node.getValue();
847
+ if (Array.isArray(currentValue)) {
848
+ const index = key === "-" ? currentValue.length : parseInt(key, 10);
849
+ currentValue.splice(index, 0, patch.value);
850
+ node.setValue([...currentValue]);
851
+ } else if (typeof currentValue === "object" && currentValue !== null) {
852
+ (currentValue as Record<string, unknown>)[key] = patch.value;
853
+ node.setValue({ ...currentValue });
854
+ }
855
+ break;
856
+ }
857
+ case "remove": {
858
+ const currentValue = node.getValue();
859
+ if (Array.isArray(currentValue)) {
860
+ const index = parseInt(key, 10);
861
+ currentValue.splice(index, 1);
862
+ node.setValue([...currentValue]);
863
+ } else if (typeof currentValue === "object" && currentValue !== null) {
864
+ delete (currentValue as Record<string, unknown>)[key];
865
+ node.setValue({ ...currentValue });
866
+ }
867
+ break;
868
+ }
869
+ }
870
+ }
871
+
872
+ /** Record patches during a function execution */
873
+ export function recordPatches(target: unknown): {
874
+ patches: IJsonPatch[];
875
+ inversePatches: IReversibleJsonPatch[];
876
+ stop: () => void;
877
+ resume: () => void;
878
+ replay: (target: unknown) => void;
879
+ undo: (target: unknown) => void;
880
+ } {
881
+ const patches: IJsonPatch[] = [];
882
+ const inversePatches: IReversibleJsonPatch[] = [];
883
+ let recording = true;
884
+
885
+ const disposer = onPatch(target, (patch, reversePatch) => {
886
+ if (recording) {
887
+ patches.push(patch);
888
+ inversePatches.push(reversePatch);
889
+ }
890
+ });
891
+
892
+ return {
893
+ patches,
894
+ inversePatches,
895
+ stop: () => {
896
+ recording = false;
897
+ disposer();
898
+ },
899
+ resume: () => {
900
+ recording = true;
901
+ },
902
+ replay: (t: unknown) => {
903
+ applyPatch(t, patches);
904
+ },
905
+ undo: (t: unknown) => {
906
+ applyPatch(t, inversePatches.slice().reverse());
907
+ },
908
+ };
909
+ }
910
+
911
+ // ============================================================================
912
+ // Action Tracking
913
+ // ============================================================================
914
+
915
+ interface ActionContext {
916
+ name: string;
917
+ args: unknown[];
918
+ tree: StateTreeNode;
919
+ }
920
+
921
+ let currentAction: ActionContext | null = null;
922
+ const actionListeners = new Set<(call: ActionCall) => void>();
923
+
924
+ /** Action recorder hooks - set by lifecycle.ts to avoid circular imports */
925
+ const actionRecorderHooks: Array<
926
+ (node: StateTreeNode, call: ActionCall) => void
927
+ > = [];
928
+
929
+ /** Register an action recorder hook (called by lifecycle.ts) */
930
+ export function registerActionRecorderHook(
931
+ hook: (node: StateTreeNode, call: ActionCall) => void,
932
+ ): () => void {
933
+ actionRecorderHooks.push(hook);
934
+ return () => {
935
+ const index = actionRecorderHooks.indexOf(hook);
936
+ if (index >= 0) {
937
+ actionRecorderHooks.splice(index, 1);
938
+ }
939
+ };
940
+ }
941
+
942
+ export interface ActionCall {
943
+ name: string;
944
+ path: string;
945
+ args: unknown[];
946
+ }
947
+
948
+ /** Track an action call */
949
+ export function trackAction<T>(
950
+ node: StateTreeNode,
951
+ name: string,
952
+ args: unknown[],
953
+ fn: () => T,
954
+ ): T {
955
+ const previousAction = currentAction;
956
+ currentAction = { name, args, tree: node };
957
+
958
+ try {
959
+ const result = fn();
960
+
961
+ // Notify action listeners
962
+ const call: ActionCall = {
963
+ name,
964
+ path: node.$path,
965
+ args,
966
+ };
967
+ actionListeners.forEach((listener) => listener(call));
968
+
969
+ // Notify action recorder hooks (registered by lifecycle.ts)
970
+ actionRecorderHooks.forEach((hook) => hook(node, call));
971
+
972
+ return result;
973
+ } finally {
974
+ currentAction = previousAction;
975
+ }
976
+ }
977
+
978
+ /** Subscribe to action calls */
979
+ export function onAction(
980
+ target: unknown,
981
+ listener: (call: ActionCall) => void,
982
+ ): IDisposer {
983
+ actionListeners.add(listener);
984
+ return () => {
985
+ actionListeners.delete(listener);
986
+ };
987
+ }
988
+
989
+ // ============================================================================
990
+ // Utilities
991
+ // ============================================================================
992
+
993
+ /** Walk the tree */
994
+ export function walk(target: unknown, visitor: (node: unknown) => void): void {
995
+ const treeNode = getStateTreeNode(target);
996
+
997
+ function visitNode(node: StateTreeNode) {
998
+ const instance = node.getInstance();
999
+ if (instance) {
1000
+ visitor(instance);
1001
+ }
1002
+ node.getChildren().forEach(visitNode);
1003
+ }
1004
+
1005
+ visitNode(treeNode);
1006
+ }
1007
+
1008
+ /** Get all members (properties) of a node */
1009
+ export function getMembers(target: unknown): {
1010
+ name: string;
1011
+ type: "view" | "action" | "property" | "volatile";
1012
+ value: unknown;
1013
+ }[] {
1014
+ const result: {
1015
+ name: string;
1016
+ type: "view" | "action" | "property" | "volatile";
1017
+ value: unknown;
1018
+ }[] = [];
1019
+ const node = getStateTreeNode(target);
1020
+ const instance = target as Record<string, unknown>;
1021
+
1022
+ // Get properties from children
1023
+ for (const [key] of node.getChildren()) {
1024
+ result.push({
1025
+ name: key,
1026
+ type: "property",
1027
+ value: instance[key],
1028
+ });
1029
+ }
1030
+
1031
+ // Get volatile state
1032
+ for (const [key, value] of Object.entries(node.volatileState)) {
1033
+ result.push({
1034
+ name: key,
1035
+ type: "volatile",
1036
+ value,
1037
+ });
1038
+ }
1039
+
1040
+ return result;
1041
+ }
1042
+
1043
+ /** Resolve a path to a node */
1044
+ export function resolvePath(target: unknown, path: string): unknown {
1045
+ const parts = path.split("/").filter(Boolean);
1046
+ let node = getStateTreeNode(target);
1047
+
1048
+ for (const part of parts) {
1049
+ const child = node.getChild(part);
1050
+ if (!child) {
1051
+ throw new Error(`[jotai-state-tree] Invalid path: ${path}`);
1052
+ }
1053
+ node = child;
1054
+ }
1055
+
1056
+ return node.getInstance();
1057
+ }
1058
+
1059
+ /** Try to resolve a path */
1060
+ export function tryResolve(target: unknown, path: string): unknown | undefined {
1061
+ try {
1062
+ return resolvePath(target, path);
1063
+ } catch {
1064
+ return undefined;
1065
+ }
1066
+ }
1067
+
1068
+ /** Get the relative path from one node to another */
1069
+ export function getRelativePath(from: unknown, to: unknown): string {
1070
+ const fromNode = getStateTreeNode(from);
1071
+ const toNode = getStateTreeNode(to);
1072
+
1073
+ const fromParts = fromNode.$path.split("/").filter(Boolean);
1074
+ const toParts = toNode.$path.split("/").filter(Boolean);
1075
+
1076
+ // Find common ancestor
1077
+ let commonLength = 0;
1078
+ for (let i = 0; i < Math.min(fromParts.length, toParts.length); i++) {
1079
+ if (fromParts[i] === toParts[i]) {
1080
+ commonLength++;
1081
+ } else {
1082
+ break;
1083
+ }
1084
+ }
1085
+
1086
+ // Build relative path
1087
+ const upCount = fromParts.length - commonLength;
1088
+ const downParts = toParts.slice(commonLength);
1089
+
1090
+ const parts: string[] = [];
1091
+ for (let i = 0; i < upCount; i++) {
1092
+ parts.push("..");
1093
+ }
1094
+ parts.push(...downParts);
1095
+
1096
+ return parts.join("/") || ".";
1097
+ }
1098
+
1099
+ /** Check if a node is an ancestor of another */
1100
+ export function isAncestor(ancestor: unknown, descendant: unknown): boolean {
1101
+ const ancestorNode = getStateTreeNode(ancestor);
1102
+ let currentNode: StateTreeNode | null = getStateTreeNode(descendant);
1103
+
1104
+ while (currentNode) {
1105
+ if (currentNode === ancestorNode) {
1106
+ return true;
1107
+ }
1108
+ currentNode = currentNode.$parent;
1109
+ }
1110
+
1111
+ return false;
1112
+ }
1113
+
1114
+ /** Check if two nodes share a common root */
1115
+ export function haveSameRoot(a: unknown, b: unknown): boolean {
1116
+ return getRoot(a) === getRoot(b);
1117
+ }
1118
+
1119
+ /** Get all nodes of a specific type in the tree */
1120
+ export function findAll<T>(
1121
+ target: unknown,
1122
+ predicate: (node: unknown) => node is T,
1123
+ ): T[] {
1124
+ const results: T[] = [];
1125
+
1126
+ walk(target, (node) => {
1127
+ if (predicate(node)) {
1128
+ results.push(node);
1129
+ }
1130
+ });
1131
+
1132
+ return results;
1133
+ }
1134
+
1135
+ /** Get the first node matching a predicate */
1136
+ export function findFirst<T>(
1137
+ target: unknown,
1138
+ predicate: (node: unknown) => node is T,
1139
+ ): T | undefined {
1140
+ let result: T | undefined;
1141
+
1142
+ walk(target, (node) => {
1143
+ if (!result && predicate(node)) {
1144
+ result = node;
1145
+ }
1146
+ });
1147
+
1148
+ return result;
1149
+ }
1150
+
1151
+ /** Check if a value is a valid reference target */
1152
+ export function isValidReference(
1153
+ target: unknown,
1154
+ identifier: string | number,
1155
+ ): boolean {
1156
+ if (!hasStateTreeNode(target)) return false;
1157
+
1158
+ const node = getStateTreeNode(target);
1159
+ const typeName = node.$type.name;
1160
+
1161
+ try {
1162
+ const resolved = resolveIdentifier(typeName, identifier);
1163
+ return resolved !== undefined;
1164
+ } catch {
1165
+ return false;
1166
+ }
1167
+ }
1168
+
1169
+ /** Get statistics about the tree */
1170
+ export function getTreeStats(target: unknown): {
1171
+ nodeCount: number;
1172
+ depth: number;
1173
+ types: Record<string, number>;
1174
+ } {
1175
+ let nodeCount = 0;
1176
+ let maxDepth = 0;
1177
+ const types: Record<string, number> = {};
1178
+
1179
+ walk(target, (node) => {
1180
+ if (!hasStateTreeNode(node)) return;
1181
+
1182
+ const stateNode = getStateTreeNode(node);
1183
+ nodeCount++;
1184
+
1185
+ const depth = stateNode.$path.split("/").filter(Boolean).length;
1186
+ maxDepth = Math.max(maxDepth, depth);
1187
+
1188
+ const typeName = stateNode.$type.name;
1189
+ types[typeName] = (types[typeName] || 0) + 1;
1190
+ });
1191
+
1192
+ return {
1193
+ nodeCount,
1194
+ depth: maxDepth,
1195
+ types,
1196
+ };
1197
+ }
1198
+
1199
+ /** Create a deep observable copy of a tree */
1200
+ export function cloneDeep<T>(target: T): T {
1201
+ const snapshot = getSnapshot(target);
1202
+ const node = getStateTreeNode(target);
1203
+ return node.$type.create(snapshot, node.$env) as T;
1204
+ }
1205
+
1206
+ /** Get or create a node by path */
1207
+ export function getOrCreatePath(
1208
+ target: unknown,
1209
+ path: string,
1210
+ creator: () => unknown,
1211
+ ): unknown {
1212
+ const parts = path.split("/").filter(Boolean);
1213
+ let node = getStateTreeNode(target);
1214
+
1215
+ for (let i = 0; i < parts.length; i++) {
1216
+ const part = parts[i];
1217
+ let child = node.getChild(part);
1218
+
1219
+ if (!child && i === parts.length - 1) {
1220
+ // Last part - create if needed
1221
+ const instance = creator();
1222
+ if (hasStateTreeNode(instance)) {
1223
+ child = getStateTreeNode(instance);
1224
+ node.addChild(part, child);
1225
+ } else {
1226
+ throw new Error(
1227
+ "[jotai-state-tree] Creator must return a state tree node",
1228
+ );
1229
+ }
1230
+ }
1231
+
1232
+ if (!child) {
1233
+ throw new Error(`[jotai-state-tree] Invalid path: ${path}`);
1234
+ }
1235
+
1236
+ node = child;
1237
+ }
1238
+
1239
+ return node.getInstance();
1240
+ }
1241
+
1242
+ /** Freeze a node, making it read-only */
1243
+ export function freeze(target: unknown): void {
1244
+ const node = getStateTreeNode(target);
1245
+ // Mark node as frozen by setting a flag in volatile state
1246
+ node.volatileState.$frozen = true;
1247
+
1248
+ // Freeze all children
1249
+ for (const [, child] of node.getChildren()) {
1250
+ const instance = child.getInstance();
1251
+ if (instance && hasStateTreeNode(instance)) {
1252
+ freeze(instance);
1253
+ }
1254
+ }
1255
+ }
1256
+
1257
+ /** Check if a node is frozen */
1258
+ export function isFrozen(target: unknown): boolean {
1259
+ const node = getStateTreeNode(target);
1260
+ return node.volatileState.$frozen === true;
1261
+ }
1262
+
1263
+ /** Unfreeze a node */
1264
+ export function unfreeze(target: unknown): void {
1265
+ const node = getStateTreeNode(target);
1266
+ delete node.volatileState.$frozen;
1267
+
1268
+ // Unfreeze all children
1269
+ for (const [, child] of node.getChildren()) {
1270
+ const instance = child.getInstance();
1271
+ if (instance && hasStateTreeNode(instance)) {
1272
+ unfreeze(instance);
1273
+ }
1274
+ }
1275
+ }