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.
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Lifecycle hooks and middleware system
3
+ * Implements afterCreate, beforeDestroy, afterAttach, beforeDetach, etc.
4
+ *
5
+ * MEMORY MANAGEMENT:
6
+ * - Uses WeakMap for all node-keyed registries to allow GC
7
+ * - Action recorders are stored in WeakMap to prevent memory leaks
8
+ * - All registries automatically clean up when nodes are garbage collected
9
+ */
10
+
11
+ import type { IDisposer } from "./types";
12
+ import {
13
+ StateTreeNode,
14
+ getStateTreeNode,
15
+ registerActionRecorderHook,
16
+ type ActionCall,
17
+ } from "./tree";
18
+
19
+ // ============================================================================
20
+ // Lifecycle Hook Types
21
+ // ============================================================================
22
+
23
+ export interface ILifecycleHooks {
24
+ afterCreate?(): void;
25
+ afterAttach?(): void;
26
+ beforeDetach?(): void;
27
+ beforeDestroy?(): void;
28
+ }
29
+
30
+ export interface IHooksConfig {
31
+ afterCreate?: () => void;
32
+ afterAttach?: () => void;
33
+ beforeDetach?: () => void;
34
+ beforeDestroy?: () => void;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Hook Registration (WeakMap - allows GC)
39
+ // ============================================================================
40
+
41
+ const nodeHooks = new WeakMap<StateTreeNode, IHooksConfig>();
42
+
43
+ /**
44
+ * Register lifecycle hooks for a node
45
+ */
46
+ export function registerHooks(node: StateTreeNode, hooks: IHooksConfig): void {
47
+ const existing = nodeHooks.get(node) || {};
48
+ nodeHooks.set(node, { ...existing, ...hooks });
49
+ }
50
+
51
+ /**
52
+ * Get hooks for a node
53
+ */
54
+ export function getHooks(node: StateTreeNode): IHooksConfig | undefined {
55
+ return nodeHooks.get(node);
56
+ }
57
+
58
+ /**
59
+ * Run afterCreate hook
60
+ */
61
+ export function runAfterCreate(node: StateTreeNode): void {
62
+ const hooks = nodeHooks.get(node);
63
+ if (hooks?.afterCreate) {
64
+ hooks.afterCreate();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Run afterAttach hook
70
+ */
71
+ export function runAfterAttach(node: StateTreeNode): void {
72
+ const hooks = nodeHooks.get(node);
73
+ if (hooks?.afterAttach) {
74
+ hooks.afterAttach();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Run beforeDetach hook
80
+ */
81
+ export function runBeforeDetach(node: StateTreeNode): void {
82
+ const hooks = nodeHooks.get(node);
83
+ if (hooks?.beforeDetach) {
84
+ hooks.beforeDetach();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Run beforeDestroy hook
90
+ */
91
+ export function runBeforeDestroy(node: StateTreeNode): void {
92
+ const hooks = nodeHooks.get(node);
93
+ if (hooks?.beforeDestroy) {
94
+ hooks.beforeDestroy();
95
+ }
96
+ }
97
+
98
+ // ============================================================================
99
+ // Middleware System
100
+ // ============================================================================
101
+
102
+ export interface IMiddlewareEvent {
103
+ type:
104
+ | "action"
105
+ | "flow_spawn"
106
+ | "flow_resume"
107
+ | "flow_resume_error"
108
+ | "flow_return"
109
+ | "flow_throw";
110
+ name: string;
111
+ id: number;
112
+ parentId: number;
113
+ rootId: number;
114
+ context: unknown;
115
+ tree: unknown;
116
+ args: unknown[];
117
+ parentEvent?: IMiddlewareEvent;
118
+ }
119
+
120
+ export interface IMiddlewareHandler {
121
+ (
122
+ call: IMiddlewareEvent,
123
+ next: (
124
+ call: IMiddlewareEvent,
125
+ callback?: (value: unknown) => unknown,
126
+ ) => unknown,
127
+ abort: (value: unknown) => unknown,
128
+ ): unknown;
129
+ }
130
+
131
+ export interface IActionContext {
132
+ name: string;
133
+ context: unknown;
134
+ tree: unknown;
135
+ args: unknown[];
136
+ parentActionEvent?: IMiddlewareEvent;
137
+ }
138
+
139
+ // Global middleware stack - these are global handlers, not per-node
140
+ const middlewareStack: IMiddlewareHandler[] = [];
141
+ let middlewareIdCounter = 0;
142
+
143
+ /**
144
+ * Add middleware to the global stack
145
+ */
146
+ export function addMiddleware(
147
+ target: unknown,
148
+ handler: IMiddlewareHandler,
149
+ includeHooks: boolean = true,
150
+ ): IDisposer {
151
+ middlewareStack.push(handler);
152
+ return () => {
153
+ const index = middlewareStack.indexOf(handler);
154
+ if (index >= 0) {
155
+ middlewareStack.splice(index, 1);
156
+ }
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Create a middleware runner
162
+ */
163
+ export function createMiddlewareRunner(
164
+ node: StateTreeNode,
165
+ actionName: string,
166
+ args: unknown[],
167
+ ): (fn: () => unknown) => unknown {
168
+ if (middlewareStack.length === 0) {
169
+ return (fn) => fn();
170
+ }
171
+
172
+ const id = ++middlewareIdCounter;
173
+ const event: IMiddlewareEvent = {
174
+ type: "action",
175
+ name: actionName,
176
+ id,
177
+ parentId: 0,
178
+ rootId: id,
179
+ context: node.getInstance(),
180
+ tree: node.getRoot().getInstance(),
181
+ args,
182
+ };
183
+
184
+ return (fn: () => unknown) => {
185
+ let index = 0;
186
+ let aborted = false;
187
+ let abortValue: unknown;
188
+
189
+ const abort = (value: unknown) => {
190
+ aborted = true;
191
+ abortValue = value;
192
+ return value;
193
+ };
194
+
195
+ const next = (
196
+ call: IMiddlewareEvent,
197
+ callback?: (value: unknown) => unknown,
198
+ ): unknown => {
199
+ if (aborted) return abortValue;
200
+
201
+ if (index >= middlewareStack.length) {
202
+ const result = fn();
203
+ return callback ? callback(result) : result;
204
+ }
205
+
206
+ const middleware = middlewareStack[index++];
207
+ return middleware(call, next, abort);
208
+ };
209
+
210
+ return next(event);
211
+ };
212
+ }
213
+
214
+ // ============================================================================
215
+ // Action Tracking Context
216
+ // ============================================================================
217
+
218
+ interface ActionCallContext {
219
+ name: string;
220
+ args: unknown[];
221
+ tree: StateTreeNode;
222
+ parentContext?: ActionCallContext;
223
+ }
224
+
225
+ let currentActionContext: ActionCallContext | null = null;
226
+
227
+ /**
228
+ * Get the current action context
229
+ */
230
+ export function getRunningActionContext(): ActionCallContext | null {
231
+ return currentActionContext;
232
+ }
233
+
234
+ /**
235
+ * Set the current action context
236
+ */
237
+ export function setRunningActionContext(
238
+ context: ActionCallContext | null,
239
+ ): void {
240
+ currentActionContext = context;
241
+ }
242
+
243
+ // ============================================================================
244
+ // Action Recording (WeakMap - allows GC of nodes)
245
+ // ============================================================================
246
+
247
+ export interface ISerializedActionCall {
248
+ name: string;
249
+ path: string;
250
+ args: unknown[];
251
+ }
252
+
253
+ /**
254
+ * WeakMap for action recorders - allows nodes to be garbage collected
255
+ * even if they have recorders attached
256
+ */
257
+ const actionRecorders = new WeakMap<
258
+ StateTreeNode,
259
+ Set<(action: ISerializedActionCall) => void>
260
+ >();
261
+
262
+ /**
263
+ * Record all actions on a subtree
264
+ *
265
+ * MEMORY SAFETY: Uses WeakMap so nodes can be garbage collected
266
+ * even while recording is active. The stop() function properly
267
+ * cleans up the recorder from the Set.
268
+ */
269
+ export function recordActions(target: unknown): {
270
+ actions: ISerializedActionCall[];
271
+ stop: () => void;
272
+ replay: (target: unknown) => void;
273
+ } {
274
+ const node = getStateTreeNode(target);
275
+ const actions: ISerializedActionCall[] = [];
276
+
277
+ const recorder = (action: ISerializedActionCall) => {
278
+ // Only record if node is still alive
279
+ if (node.$isAlive) {
280
+ actions.push(action);
281
+ }
282
+ };
283
+
284
+ let recorders = actionRecorders.get(node);
285
+ if (!recorders) {
286
+ recorders = new Set();
287
+ actionRecorders.set(node, recorders);
288
+ }
289
+ recorders.add(recorder);
290
+
291
+ return {
292
+ actions,
293
+ stop: () => {
294
+ const currentRecorders = actionRecorders.get(node);
295
+ if (currentRecorders) {
296
+ currentRecorders.delete(recorder);
297
+ // Clean up empty Sets to avoid memory waste
298
+ if (currentRecorders.size === 0) {
299
+ // WeakMap doesn't have delete, but setting to undefined
300
+ // or just leaving empty Set is fine - WeakMap handles GC
301
+ }
302
+ }
303
+ },
304
+ replay: (replayTarget: unknown) => {
305
+ const replayNode = getStateTreeNode(replayTarget);
306
+ for (const action of actions) {
307
+ const instance = replayNode.getInstance() as Record<string, Function>;
308
+ if (typeof instance[action.name] === "function") {
309
+ instance[action.name](...action.args);
310
+ }
311
+ }
312
+ },
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Notify action recorders
318
+ */
319
+ export function notifyActionRecorders(
320
+ node: StateTreeNode,
321
+ action: ISerializedActionCall,
322
+ ): void {
323
+ // Walk up the tree and notify all recorders
324
+ let current: StateTreeNode | null = node;
325
+ while (current) {
326
+ const recorders = actionRecorders.get(current);
327
+ if (recorders) {
328
+ recorders.forEach((recorder) => recorder(action));
329
+ }
330
+ current = current.$parent;
331
+ }
332
+ }
333
+
334
+ // Register the action recorder hook with tree.ts
335
+ // This is called at module load time to connect action tracking with recording
336
+ registerActionRecorderHook((node: StateTreeNode, call: ActionCall) => {
337
+ const action: ISerializedActionCall = {
338
+ name: call.name,
339
+ path: call.path,
340
+ args: call.args,
341
+ };
342
+ notifyActionRecorders(node, action);
343
+ });
344
+
345
+ // ============================================================================
346
+ // Protect / Unprotect (WeakSet - allows GC)
347
+ // ============================================================================
348
+
349
+ const protectedNodes = new WeakSet<StateTreeNode>();
350
+
351
+ /**
352
+ * Protect a node from direct mutations outside of actions
353
+ */
354
+ export function protect(target: unknown): void {
355
+ const node = getStateTreeNode(target);
356
+ protectedNodes.add(node);
357
+ }
358
+
359
+ /**
360
+ * Unprotect a node to allow direct mutations
361
+ */
362
+ export function unprotect(target: unknown): void {
363
+ const node = getStateTreeNode(target);
364
+ protectedNodes.delete(node);
365
+ }
366
+
367
+ /**
368
+ * Check if a node is protected
369
+ */
370
+ export function isProtected(target: unknown): boolean {
371
+ const node = getStateTreeNode(target);
372
+ return protectedNodes.has(node);
373
+ }
374
+
375
+ /**
376
+ * Check if we can write to a node
377
+ */
378
+ export function canWrite(node: StateTreeNode): boolean {
379
+ // If not protected, can always write
380
+ if (!protectedNodes.has(node)) {
381
+ return true;
382
+ }
383
+
384
+ // If protected, must be inside an action
385
+ return currentActionContext !== null;
386
+ }
387
+
388
+ // ============================================================================
389
+ // Type Checking
390
+ // ============================================================================
391
+
392
+ /**
393
+ * Type check a value against a type, throwing if invalid
394
+ */
395
+ export function typecheck<T>(
396
+ type: { is(v: unknown): v is T; name: string },
397
+ value: unknown,
398
+ ): void {
399
+ if (!type.is(value)) {
400
+ throw new Error(
401
+ `[jotai-state-tree] Value ${JSON.stringify(value)} is not assignable to type '${type.name}'`,
402
+ );
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Try to resolve a value as an instance of a type
408
+ */
409
+ export function tryResolve<T>(
410
+ type: { create(s: unknown): T; is(v: unknown): v is T },
411
+ value: unknown,
412
+ ): T | undefined {
413
+ try {
414
+ if (type.is(value)) {
415
+ return value;
416
+ }
417
+ return type.create(value);
418
+ } catch {
419
+ return undefined;
420
+ }
421
+ }
422
+
423
+ // ============================================================================
424
+ // Utility: getType
425
+ // ============================================================================
426
+
427
+ /**
428
+ * Get the type of a state tree node
429
+ */
430
+ export function getType(target: unknown): unknown {
431
+ const node = getStateTreeNode(target);
432
+ return node.$type;
433
+ }
434
+
435
+ /**
436
+ * Check if a value is of a specific type
437
+ */
438
+ export function isType(
439
+ value: unknown,
440
+ type: { is(v: unknown): boolean },
441
+ ): boolean {
442
+ return type.is(value);
443
+ }
444
+
445
+ // ============================================================================
446
+ // Utility: getChildType
447
+ // ============================================================================
448
+
449
+ /**
450
+ * Get the type of a child property
451
+ */
452
+ export function getChildType(target: unknown, propertyName: string): unknown {
453
+ const node = getStateTreeNode(target);
454
+ const type = node.$type as { properties?: Record<string, unknown> };
455
+
456
+ if (type.properties && propertyName in type.properties) {
457
+ return type.properties[propertyName];
458
+ }
459
+
460
+ throw new Error(
461
+ `[jotai-state-tree] Property '${propertyName}' not found on type '${(type as { name?: string }).name}'`,
462
+ );
463
+ }
464
+
465
+ // ============================================================================
466
+ // Apply Action
467
+ // ============================================================================
468
+
469
+ /**
470
+ * Apply an action call to a target
471
+ */
472
+ export function applyAction(
473
+ target: unknown,
474
+ action: ISerializedActionCall,
475
+ ): unknown {
476
+ const node = getStateTreeNode(target);
477
+
478
+ // Navigate to the correct node using path
479
+ let currentNode = node;
480
+ if (action.path) {
481
+ const parts = action.path.split("/").filter(Boolean);
482
+ for (const part of parts) {
483
+ const child = currentNode.getChild(part);
484
+ if (!child) {
485
+ throw new Error(
486
+ `[jotai-state-tree] Invalid action path: ${action.path}`,
487
+ );
488
+ }
489
+ currentNode = child;
490
+ }
491
+ }
492
+
493
+ const instance = currentNode.getInstance() as Record<string, Function>;
494
+ if (typeof instance[action.name] !== "function") {
495
+ throw new Error(`[jotai-state-tree] Action '${action.name}' not found`);
496
+ }
497
+
498
+ return instance[action.name](...action.args);
499
+ }
500
+
501
+ // ============================================================================
502
+ // Escaping / Unescaping JSON Pointer
503
+ // ============================================================================
504
+
505
+ /**
506
+ * Escape a JSON pointer segment
507
+ */
508
+ export function escapeJsonPath(path: string): string {
509
+ return path.replace(/~/g, "~0").replace(/\//g, "~1");
510
+ }
511
+
512
+ /**
513
+ * Unescape a JSON pointer segment
514
+ */
515
+ export function unescapeJsonPath(path: string): string {
516
+ return path.replace(/~1/g, "/").replace(/~0/g, "~");
517
+ }
518
+
519
+ /**
520
+ * Split a path into segments
521
+ */
522
+ export function splitJsonPath(path: string): string[] {
523
+ return path.split("/").filter(Boolean).map(unescapeJsonPath);
524
+ }
525
+
526
+ /**
527
+ * Join path segments
528
+ */
529
+ export function joinJsonPath(parts: string[]): string {
530
+ return parts.map(escapeJsonPath).join("/");
531
+ }
532
+
533
+ // ============================================================================
534
+ // Dependency Tracking
535
+ // ============================================================================
536
+
537
+ interface DependencyTracker {
538
+ track(atom: unknown): void;
539
+ getTracked(): Set<unknown>;
540
+ }
541
+
542
+ let currentTracker: DependencyTracker | null = null;
543
+
544
+ /**
545
+ * Create a dependency tracker
546
+ */
547
+ export function createDependencyTracker(): DependencyTracker {
548
+ const tracked = new Set<unknown>();
549
+ return {
550
+ track(atom: unknown) {
551
+ tracked.add(atom);
552
+ },
553
+ getTracked() {
554
+ return tracked;
555
+ },
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Run a function with dependency tracking
561
+ */
562
+ export function withDependencyTracking<T>(
563
+ tracker: DependencyTracker,
564
+ fn: () => T,
565
+ ): T {
566
+ const previous = currentTracker;
567
+ currentTracker = tracker;
568
+ try {
569
+ return fn();
570
+ } finally {
571
+ currentTracker = previous;
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Track a dependency
577
+ */
578
+ export function trackDependency(atom: unknown): void {
579
+ currentTracker?.track(atom);
580
+ }