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/model.ts ADDED
@@ -0,0 +1,832 @@
1
+ /**
2
+ * Model type implementation
3
+ * This is the core of jotai-state-tree
4
+ *
5
+ * MEMORY MANAGEMENT:
6
+ * - View and action caches are bounded with LRU eviction
7
+ * - Caches are instance-scoped, so they're GC'd with the instance
8
+ * - No global caches that could accumulate entries
9
+ */
10
+
11
+ import { atom, type WritableAtom } from "jotai";
12
+ import type {
13
+ IModelType,
14
+ ModelProperties,
15
+ ModelCreationType,
16
+ ModelSnapshotType,
17
+ ModelInstance,
18
+ ModelViews,
19
+ ModelActions,
20
+ ModelVolatile,
21
+ IType,
22
+ IValidationContext,
23
+ IValidationResult,
24
+ IAnyType,
25
+ } from "./types";
26
+ import {
27
+ StateTreeNode,
28
+ $treenode,
29
+ getStateTreeNode,
30
+ trackAction,
31
+ getGlobalStore,
32
+ } from "./tree";
33
+
34
+ // ============================================================================
35
+ // LRU Cache Implementation
36
+ // ============================================================================
37
+
38
+ /** Maximum entries in view/action caches per instance */
39
+ const MAX_CACHE_SIZE = 100;
40
+
41
+ /**
42
+ * Simple LRU cache with bounded size
43
+ * When capacity is reached, oldest entries are evicted
44
+ */
45
+ class LRUCache<K, V> {
46
+ private cache = new Map<K, V>();
47
+ private maxSize: number;
48
+
49
+ constructor(maxSize: number = MAX_CACHE_SIZE) {
50
+ this.maxSize = maxSize;
51
+ }
52
+
53
+ get(key: K): V | undefined {
54
+ const value = this.cache.get(key);
55
+ if (value !== undefined) {
56
+ // Move to end (most recently used)
57
+ this.cache.delete(key);
58
+ this.cache.set(key, value);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ set(key: K, value: V): void {
64
+ // Delete first if exists to update position
65
+ if (this.cache.has(key)) {
66
+ this.cache.delete(key);
67
+ } else if (this.cache.size >= this.maxSize) {
68
+ // Evict oldest (first) entry
69
+ const firstKey = this.cache.keys().next().value;
70
+ if (firstKey !== undefined) {
71
+ this.cache.delete(firstKey);
72
+ }
73
+ }
74
+ this.cache.set(key, value);
75
+ }
76
+
77
+ has(key: K): boolean {
78
+ return this.cache.has(key);
79
+ }
80
+
81
+ clear(): void {
82
+ this.cache.clear();
83
+ }
84
+
85
+ get size(): number {
86
+ return this.cache.size;
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Model Type Factory
92
+ // ============================================================================
93
+
94
+ interface LifecycleHooks<Self> {
95
+ afterCreate?: (self: Self) => void;
96
+ afterAttach?: (self: Self) => void;
97
+ beforeDetach?: (self: Self) => void;
98
+ beforeDestroy?: (self: Self) => void;
99
+ }
100
+
101
+ interface ModelTypeConfig<
102
+ P extends ModelProperties,
103
+ V extends object,
104
+ A extends object,
105
+ Vol extends object,
106
+ > {
107
+ name: string;
108
+ properties: P;
109
+ views: ModelViews<ModelInstance<P, V, A, Vol> & V & A & Vol, V>[];
110
+ actions: ModelActions<ModelInstance<P, V, A, Vol> & V & A & Vol, A>[];
111
+ volatiles: ModelVolatile<ModelInstance<P, V, A, Vol> & V & A & Vol, Vol>[];
112
+ preProcessor?: (snapshot: unknown) => ModelCreationType<P>;
113
+ postProcessor?: (snapshot: ModelSnapshotType<P>) => unknown;
114
+ initializers: Array<
115
+ (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => void
116
+ >;
117
+ hooks: LifecycleHooks<ModelInstance<P, V, A, Vol> & V & A & Vol>;
118
+ }
119
+
120
+ class ModelType<
121
+ P extends ModelProperties,
122
+ V extends object,
123
+ A extends object,
124
+ Vol extends object,
125
+ > implements IModelType<P, V, A, Vol>
126
+ {
127
+ readonly _kind = "model" as const;
128
+ readonly _C!: ModelCreationType<P>;
129
+ readonly _S!: ModelSnapshotType<P>;
130
+ readonly _T!: ModelInstance<P, V, A, Vol> & V & A & Vol;
131
+
132
+ readonly name: string;
133
+ readonly properties: P;
134
+ readonly identifierAttribute?: string;
135
+
136
+ private config: ModelTypeConfig<P, V, A, Vol>;
137
+
138
+ constructor(config: ModelTypeConfig<P, V, A, Vol>) {
139
+ this.config = config;
140
+ this.name = config.name;
141
+ this.properties = config.properties;
142
+
143
+ // Find identifier attribute
144
+ for (const [key, type] of Object.entries(config.properties)) {
145
+ if (
146
+ (type as IAnyType)._kind === "identifier" ||
147
+ (type as IAnyType)._kind === "identifierNumber"
148
+ ) {
149
+ this.identifierAttribute = key;
150
+ break;
151
+ }
152
+ }
153
+ }
154
+
155
+ create(
156
+ snapshot?: ModelCreationType<P>,
157
+ env?: unknown,
158
+ ): ModelInstance<P, V, A, Vol> & V & A & Vol {
159
+ // Apply pre-processor if exists
160
+ let processedSnapshot = snapshot ?? {};
161
+ if (this.config.preProcessor) {
162
+ processedSnapshot = this.config.preProcessor(
163
+ processedSnapshot,
164
+ ) as ModelCreationType<P>;
165
+ }
166
+
167
+ // Create the tree node
168
+ const node = new StateTreeNode(this, processedSnapshot, env);
169
+ node.preProcessor = this.config.preProcessor as
170
+ | ((snapshot: unknown) => unknown)
171
+ | undefined;
172
+ node.postProcessor = this.config.postProcessor as
173
+ | ((snapshot: unknown) => unknown)
174
+ | undefined;
175
+
176
+ // Create property atoms and child nodes
177
+ const propertyAtoms = new Map<
178
+ string,
179
+ WritableAtom<unknown, [unknown], void>
180
+ >();
181
+ const store = getGlobalStore();
182
+
183
+ for (const [key, propType] of Object.entries(this.properties)) {
184
+ const type = propType as IAnyType;
185
+ const initialValue = (processedSnapshot as Record<string, unknown>)?.[
186
+ key
187
+ ];
188
+
189
+ // Check if this is a complex type that creates its own tree node
190
+ // This includes direct model/array/map, or wrapper types like maybe/late that contain them
191
+ const isComplexType =
192
+ type._kind === "model" ||
193
+ type._kind === "array" ||
194
+ type._kind === "map";
195
+
196
+ if (isComplexType) {
197
+ // Complex types create their own nodes
198
+ const childInstance = type.create(initialValue, env);
199
+ const childNode = getStateTreeNode(childInstance);
200
+ node.addChild(key, childNode);
201
+ propertyAtoms.set(key, childNode.valueAtom);
202
+ } else {
203
+ // For wrapper types (maybe, late, optional, etc.), create the value first
204
+ // and check if it has a tree node (meaning it wraps a complex type)
205
+ const value = type.create(initialValue, env);
206
+
207
+ // Check if the created value has a tree node (complex type inside wrapper)
208
+ if (value && typeof value === "object" && $treenode in value) {
209
+ const childNode = getStateTreeNode(value);
210
+ node.addChild(key, childNode);
211
+ propertyAtoms.set(key, childNode.valueAtom);
212
+ } else {
213
+ // Simple/primitive types use direct atoms
214
+ const propAtom = atom(value);
215
+ propertyAtoms.set(key, propAtom);
216
+
217
+ // Create a "virtual" child node for the property
218
+ const childNode = new StateTreeNode(type, value, env, node, key);
219
+ childNode.valueAtom = propAtom as unknown as WritableAtom<
220
+ unknown,
221
+ [unknown],
222
+ void
223
+ >;
224
+ node.addChild(key, childNode);
225
+ }
226
+ }
227
+ }
228
+
229
+ // Build the instance proxy
230
+ const instance = this.createInstanceProxy(node, propertyAtoms, store);
231
+
232
+ // Register identifier if present
233
+ if (this.identifierAttribute) {
234
+ const idValue = (processedSnapshot as Record<string, unknown>)?.[
235
+ this.identifierAttribute
236
+ ];
237
+ if (idValue !== undefined) {
238
+ node.registerIdentifier(this.name, idValue as string | number);
239
+ }
240
+ }
241
+
242
+ // Set instance on node
243
+ node.setInstance(instance);
244
+
245
+ // Run initializers (afterCreate hooks)
246
+ for (const initializer of this.config.initializers) {
247
+ initializer(instance);
248
+ }
249
+
250
+ // Run afterCreate lifecycle hook
251
+ if (this.config.hooks.afterCreate) {
252
+ this.config.hooks.afterCreate(instance);
253
+ }
254
+
255
+ return instance;
256
+ }
257
+
258
+ private createInstanceProxy(
259
+ node: StateTreeNode,
260
+ propertyAtoms: Map<string, WritableAtom<unknown, [unknown], void>>,
261
+ store: ReturnType<typeof getGlobalStore>,
262
+ ): ModelInstance<P, V, A, Vol> & V & A & Vol {
263
+ const self = this;
264
+ // Use bounded LRU caches to prevent unbounded memory growth
265
+ // These caches are instance-scoped and will be GC'd with the instance
266
+ const viewCache = new LRUCache<string, unknown>(MAX_CACHE_SIZE);
267
+ const actionCache = new LRUCache<string, Function>(MAX_CACHE_SIZE);
268
+
269
+ // Collect all views
270
+ const allViews: Record<string, PropertyDescriptor> = {};
271
+
272
+ // Collect all actions
273
+ const allActions: Record<string, Function> = {};
274
+
275
+ // Collect volatile state
276
+ const volatileState: Record<string, unknown> = {};
277
+
278
+ // Create base object with tree node reference
279
+ const base = {
280
+ [$treenode]: node,
281
+ };
282
+
283
+ // Create the proxy
284
+ const proxy = new Proxy(base, {
285
+ get(target, prop) {
286
+ // Handle symbol access
287
+ if (prop === $treenode) {
288
+ return node;
289
+ }
290
+
291
+ // Handle $treenode string access
292
+ if (prop === "$treenode") {
293
+ return node;
294
+ }
295
+
296
+ const propStr = String(prop);
297
+
298
+ // Check properties first
299
+ if (propertyAtoms.has(propStr)) {
300
+ const childNode = node.getChild(propStr);
301
+ if (childNode) {
302
+ // Check if the child node has an instance (complex types like model, array, map)
303
+ // This handles both direct complex types and wrapper types (maybe, late, optional)
304
+ // that contain complex types
305
+ const instance = childNode.getInstance();
306
+ if (instance !== undefined) {
307
+ // Check if instance is a state tree node (complex type)
308
+ if (
309
+ instance &&
310
+ typeof instance === "object" &&
311
+ $treenode in instance
312
+ ) {
313
+ return instance;
314
+ }
315
+ }
316
+ // For primitive types, get from atom
317
+ return store.get(propertyAtoms.get(propStr)!);
318
+ }
319
+ }
320
+
321
+ // Check volatile state
322
+ if (propStr in node.volatileState) {
323
+ return node.volatileState[propStr];
324
+ }
325
+
326
+ // Check views
327
+ if (propStr in allViews) {
328
+ const descriptor = allViews[propStr];
329
+ if (descriptor.get) {
330
+ return descriptor.get.call(proxy);
331
+ }
332
+ if (typeof descriptor.value === "function") {
333
+ return descriptor.value.bind(proxy);
334
+ }
335
+ return descriptor.value;
336
+ }
337
+
338
+ // Check actions
339
+ if (propStr in allActions) {
340
+ return allActions[propStr];
341
+ }
342
+
343
+ return undefined;
344
+ },
345
+
346
+ set(target, prop, value) {
347
+ const propStr = String(prop);
348
+
349
+ // Check if it's a property
350
+ if (propertyAtoms.has(propStr)) {
351
+ const propType = (self.properties as Record<string, IAnyType>)[
352
+ propStr
353
+ ];
354
+ const existingChildNode = node.getChild(propStr);
355
+
356
+ // Handle direct complex types
357
+ if (propType._kind === "model") {
358
+ if (existingChildNode) {
359
+ // For models, apply snapshot
360
+ const { applySnapshotToNode } = require("./tree");
361
+ applySnapshotToNode(existingChildNode, value);
362
+ }
363
+ return true;
364
+ }
365
+
366
+ if (propType._kind === "array" || propType._kind === "map") {
367
+ if (existingChildNode) {
368
+ // For arrays/maps, replace content
369
+ existingChildNode.setValue(value);
370
+ }
371
+ return true;
372
+ }
373
+
374
+ // Handle wrapper types (maybe, late, optional, etc.) and primitives
375
+ // These may contain complex types that need proper lifecycle management
376
+
377
+ // Get old value for patch
378
+ const oldValue = existingChildNode?.getValue();
379
+
380
+ // Destroy the old child node if it exists
381
+ if (existingChildNode) {
382
+ existingChildNode.destroy();
383
+ node.getChildren().delete(propStr);
384
+ }
385
+
386
+ // Create new value through the type
387
+ const newValue = propType.create(value, node.$env);
388
+
389
+ // Check if the new value is a complex type (has tree node)
390
+ if (
391
+ newValue &&
392
+ typeof newValue === "object" &&
393
+ $treenode in newValue
394
+ ) {
395
+ const newChildNode = getStateTreeNode(newValue);
396
+ node.addChild(propStr, newChildNode);
397
+ propertyAtoms.set(propStr, newChildNode.valueAtom);
398
+ } else {
399
+ // Primitive value - create a new child node for it
400
+ const newChildNode = new StateTreeNode(
401
+ propType,
402
+ newValue,
403
+ node.$env,
404
+ node,
405
+ propStr,
406
+ );
407
+ const propAtom = atom(newValue);
408
+ newChildNode.valueAtom = propAtom as unknown as WritableAtom<
409
+ unknown,
410
+ [unknown],
411
+ void
412
+ >;
413
+ node.addChild(propStr, newChildNode);
414
+ propertyAtoms.set(propStr, propAtom);
415
+ store.set(propAtom, newValue);
416
+ }
417
+
418
+ // Notify about the change - use node's notification methods
419
+ node.notifyPropertyChange(propStr, newValue, oldValue);
420
+
421
+ return true;
422
+ }
423
+
424
+ // Check if it's volatile state
425
+ if (propStr in node.volatileState) {
426
+ node.volatileState[propStr] = value;
427
+ return true;
428
+ }
429
+
430
+ return false;
431
+ },
432
+
433
+ has(target, prop) {
434
+ const propStr = String(prop);
435
+ return (
436
+ prop === $treenode ||
437
+ propertyAtoms.has(propStr) ||
438
+ propStr in allViews ||
439
+ propStr in allActions ||
440
+ propStr in node.volatileState
441
+ );
442
+ },
443
+
444
+ ownKeys() {
445
+ return [
446
+ ...propertyAtoms.keys(),
447
+ ...Object.keys(allViews),
448
+ ...Object.keys(allActions),
449
+ ...Object.keys(node.volatileState),
450
+ ];
451
+ },
452
+
453
+ getOwnPropertyDescriptor(target, prop) {
454
+ const propStr = String(prop);
455
+ if (
456
+ propertyAtoms.has(propStr) ||
457
+ propStr in allViews ||
458
+ propStr in allActions ||
459
+ propStr in node.volatileState
460
+ ) {
461
+ return {
462
+ configurable: true,
463
+ enumerable: true,
464
+ writable: true,
465
+ };
466
+ }
467
+ return undefined;
468
+ },
469
+ }) as unknown as ModelInstance<P, V, A, Vol> & V & A & Vol;
470
+
471
+ // Initialize views
472
+ for (const viewFn of this.config.views) {
473
+ const views = viewFn(proxy);
474
+ for (const [key, value] of Object.entries(
475
+ Object.getOwnPropertyDescriptors(views),
476
+ )) {
477
+ allViews[key] = value;
478
+ }
479
+ }
480
+
481
+ // Initialize actions
482
+ for (const actionFn of this.config.actions) {
483
+ const actions = actionFn(proxy);
484
+ for (const [key, value] of Object.entries(actions)) {
485
+ if (typeof value === "function") {
486
+ // Wrap action with tracking
487
+ allActions[key] = (...args: unknown[]) => {
488
+ return trackAction(node, key, args, () => {
489
+ return (value as Function).apply(proxy, args);
490
+ });
491
+ };
492
+ }
493
+ }
494
+ }
495
+
496
+ // Initialize volatile state
497
+ for (const volatileFn of this.config.volatiles) {
498
+ const volatile = volatileFn(proxy);
499
+ Object.assign(node.volatileState, volatile);
500
+ }
501
+
502
+ return proxy;
503
+ }
504
+
505
+ is(value: unknown): value is ModelInstance<P, V, A, Vol> & V & A & Vol {
506
+ if (!value || typeof value !== "object") return false;
507
+ if (!($treenode in value)) return false;
508
+ const node = (value as Record<typeof $treenode, StateTreeNode>)[$treenode];
509
+ return node.$type === this || node.$type.name === this.name;
510
+ }
511
+
512
+ validate(value: unknown, context: IValidationContext[]): IValidationResult {
513
+ const errors: IValidationResult["errors"] = [];
514
+
515
+ if (!value || typeof value !== "object") {
516
+ return {
517
+ valid: false,
518
+ errors: [
519
+ {
520
+ context,
521
+ value,
522
+ message: `Value is not an object`,
523
+ },
524
+ ],
525
+ };
526
+ }
527
+
528
+ // Validate each property
529
+ for (const [key, propType] of Object.entries(this.properties)) {
530
+ const propValue = (value as Record<string, unknown>)[key];
531
+ const propContext: IValidationContext = {
532
+ path: context.length > 0 ? `${context[0].path}/${key}` : `/${key}`,
533
+ type: propType as IAnyType,
534
+ parent: value,
535
+ };
536
+
537
+ const result = (propType as IAnyType).validate(propValue, [
538
+ ...context,
539
+ propContext,
540
+ ]);
541
+ if (!result.valid) {
542
+ errors.push(...result.errors);
543
+ }
544
+ }
545
+
546
+ return {
547
+ valid: errors.length === 0,
548
+ errors,
549
+ };
550
+ }
551
+
552
+ // ============================================================================
553
+ // Model Modifiers
554
+ // ============================================================================
555
+
556
+ named(name: string): IModelType<P, V, A, Vol> {
557
+ return new ModelType({
558
+ ...this.config,
559
+ name,
560
+ });
561
+ }
562
+
563
+ props<P2 extends ModelProperties>(
564
+ properties: P2,
565
+ ): IModelType<P & P2, V, A, Vol> {
566
+ return new ModelType({
567
+ ...this.config,
568
+ properties: { ...this.config.properties, ...properties },
569
+ }) as unknown as IModelType<P & P2, V, A, Vol>;
570
+ }
571
+
572
+ views<V2 extends object>(
573
+ fn: ModelViews<ModelInstance<P, V, A, Vol> & V & A & Vol, V2>,
574
+ ): IModelType<P, V & V2, A, Vol> {
575
+ return new ModelType({
576
+ ...this.config,
577
+ views: [
578
+ ...this.config.views,
579
+ fn as unknown as ModelViews<
580
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
581
+ V
582
+ >,
583
+ ],
584
+ }) as unknown as IModelType<P, V & V2, A, Vol>;
585
+ }
586
+
587
+ actions<A2 extends object>(
588
+ fn: ModelActions<ModelInstance<P, V, A, Vol> & V & A & Vol, A2>,
589
+ ): IModelType<P, V, A & A2, Vol> {
590
+ return new ModelType({
591
+ ...this.config,
592
+ actions: [
593
+ ...this.config.actions,
594
+ fn as unknown as ModelActions<
595
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
596
+ A
597
+ >,
598
+ ],
599
+ }) as unknown as IModelType<P, V, A & A2, Vol>;
600
+ }
601
+
602
+ volatile<Vol2 extends object>(
603
+ fn: ModelVolatile<ModelInstance<P, V, A, Vol> & V & A & Vol, Vol2>,
604
+ ): IModelType<P, V, A, Vol & Vol2> {
605
+ return new ModelType({
606
+ ...this.config,
607
+ volatiles: [
608
+ ...this.config.volatiles,
609
+ fn as unknown as ModelVolatile<
610
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
611
+ Vol
612
+ >,
613
+ ],
614
+ }) as unknown as IModelType<P, V, A, Vol & Vol2>;
615
+ }
616
+
617
+ preProcessSnapshot<NewC>(
618
+ fn: (snapshot: NewC) => ModelCreationType<P>,
619
+ ): IModelType<P, V, A, Vol> {
620
+ return new ModelType({
621
+ ...this.config,
622
+ preProcessor: fn as unknown as (
623
+ snapshot: unknown,
624
+ ) => ModelCreationType<P>,
625
+ });
626
+ }
627
+
628
+ postProcessSnapshot<NewS>(
629
+ fn: (snapshot: ModelSnapshotType<P>) => NewS,
630
+ ): IModelType<P, V, A, Vol> {
631
+ return new ModelType({
632
+ ...this.config,
633
+ postProcessor: fn as unknown as (
634
+ snapshot: ModelSnapshotType<P>,
635
+ ) => unknown,
636
+ });
637
+ }
638
+
639
+ extend<
640
+ V2 extends object = object,
641
+ A2 extends object = object,
642
+ Vol2 extends object = object,
643
+ >(
644
+ fn: (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => {
645
+ views?: V2;
646
+ actions?: A2;
647
+ state?: Vol2;
648
+ },
649
+ ): IModelType<P, V & V2, A & A2, Vol & Vol2> {
650
+ // Create wrapper functions for views, actions, and volatile
651
+ const viewsFn = (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => {
652
+ const result = fn(self);
653
+ return (result.views ?? {}) as V2;
654
+ };
655
+
656
+ const actionsFn = (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => {
657
+ const result = fn(self);
658
+ return (result.actions ?? {}) as A2;
659
+ };
660
+
661
+ const volatileFn = (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => {
662
+ const result = fn(self);
663
+ return (result.state ?? {}) as Vol2;
664
+ };
665
+
666
+ return new ModelType({
667
+ ...this.config,
668
+ views: [
669
+ ...this.config.views,
670
+ viewsFn as unknown as ModelViews<
671
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
672
+ V
673
+ >,
674
+ ],
675
+ actions: [
676
+ ...this.config.actions,
677
+ actionsFn as unknown as ModelActions<
678
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
679
+ A
680
+ >,
681
+ ],
682
+ volatiles: [
683
+ ...this.config.volatiles,
684
+ volatileFn as unknown as ModelVolatile<
685
+ ModelInstance<P, V, A, Vol> & V & A & Vol,
686
+ Vol
687
+ >,
688
+ ],
689
+ }) as unknown as IModelType<P, V & V2, A & A2, Vol & Vol2>;
690
+ }
691
+
692
+ /**
693
+ * Add afterCreate lifecycle hook
694
+ */
695
+ afterCreate(
696
+ fn: (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => void,
697
+ ): IModelType<P, V, A, Vol> {
698
+ return new ModelType({
699
+ ...this.config,
700
+ hooks: {
701
+ ...this.config.hooks,
702
+ afterCreate: fn,
703
+ },
704
+ });
705
+ }
706
+
707
+ /**
708
+ * Add afterAttach lifecycle hook
709
+ */
710
+ afterAttach(
711
+ fn: (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => void,
712
+ ): IModelType<P, V, A, Vol> {
713
+ return new ModelType({
714
+ ...this.config,
715
+ hooks: {
716
+ ...this.config.hooks,
717
+ afterAttach: fn,
718
+ },
719
+ });
720
+ }
721
+
722
+ /**
723
+ * Add beforeDetach lifecycle hook
724
+ */
725
+ beforeDetach(
726
+ fn: (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => void,
727
+ ): IModelType<P, V, A, Vol> {
728
+ return new ModelType({
729
+ ...this.config,
730
+ hooks: {
731
+ ...this.config.hooks,
732
+ beforeDetach: fn,
733
+ },
734
+ });
735
+ }
736
+
737
+ /**
738
+ * Add beforeDestroy lifecycle hook
739
+ */
740
+ beforeDestroy(
741
+ fn: (self: ModelInstance<P, V, A, Vol> & V & A & Vol) => void,
742
+ ): IModelType<P, V, A, Vol> {
743
+ return new ModelType({
744
+ ...this.config,
745
+ hooks: {
746
+ ...this.config.hooks,
747
+ beforeDestroy: fn,
748
+ },
749
+ });
750
+ }
751
+ }
752
+
753
+ // ============================================================================
754
+ // Model Factory Function
755
+ // ============================================================================
756
+
757
+ export function model<P extends ModelProperties>(
758
+ name: string,
759
+ properties: P,
760
+ ): IModelType<P, object, object, object>;
761
+
762
+ export function model<P extends ModelProperties>(
763
+ properties: P,
764
+ ): IModelType<P, object, object, object>;
765
+
766
+ export function model<P extends ModelProperties>(
767
+ nameOrProperties: string | P,
768
+ maybeProperties?: P,
769
+ ): IModelType<P, object, object, object> {
770
+ const name =
771
+ typeof nameOrProperties === "string" ? nameOrProperties : "AnonymousModel";
772
+ const properties =
773
+ typeof nameOrProperties === "string" ? maybeProperties! : nameOrProperties;
774
+
775
+ return new ModelType({
776
+ name,
777
+ properties,
778
+ views: [],
779
+ actions: [],
780
+ volatiles: [],
781
+ initializers: [],
782
+ hooks: {},
783
+ });
784
+ }
785
+
786
+ // ============================================================================
787
+ // Compose Models
788
+ // ============================================================================
789
+
790
+ export function compose<
791
+ PA extends ModelProperties,
792
+ PB extends ModelProperties,
793
+ VA extends object,
794
+ VB extends object,
795
+ AA extends object,
796
+ AB extends object,
797
+ VolA extends object,
798
+ VolB extends object,
799
+ >(
800
+ name: string,
801
+ a: IModelType<PA, VA, AA, VolA>,
802
+ b: IModelType<PB, VB, AB, VolB>,
803
+ ): IModelType<PA & PB, VA & VB, AA & AB, VolA & VolB>;
804
+
805
+ export function compose<
806
+ PA extends ModelProperties,
807
+ PB extends ModelProperties,
808
+ VA extends object,
809
+ VB extends object,
810
+ AA extends object,
811
+ AB extends object,
812
+ VolA extends object,
813
+ VolB extends object,
814
+ >(
815
+ a: IModelType<PA, VA, AA, VolA>,
816
+ b: IModelType<PB, VB, AB, VolB>,
817
+ ): IModelType<PA & PB, VA & VB, AA & AB, VolA & VolB>;
818
+
819
+ export function compose(...args: unknown[]): unknown {
820
+ const name = typeof args[0] === "string" ? args[0] : "ComposedModel";
821
+ const types = (
822
+ typeof args[0] === "string" ? args.slice(1) : args
823
+ ) as IModelType<ModelProperties, object, object, object>[];
824
+
825
+ // Merge all properties
826
+ const mergedProperties: ModelProperties = {};
827
+ for (const type of types) {
828
+ Object.assign(mergedProperties, type.properties);
829
+ }
830
+
831
+ return model(name, mergedProperties);
832
+ }