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/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/chunk-XXZK62DD.mjs +931 -0
- package/dist/index.d.mts +1109 -0
- package/dist/index.d.ts +1109 -0
- package/dist/index.js +3579 -0
- package/dist/index.mjs +2625 -0
- package/dist/react.d.mts +144 -0
- package/dist/react.d.ts +144 -0
- package/dist/react.js +1259 -0
- package/dist/react.mjs +372 -0
- package/package.json +77 -0
- package/src/__tests__/index.test.ts +1371 -0
- package/src/__tests__/memory.test.ts +681 -0
- package/src/__tests__/performance.test.ts +667 -0
- package/src/__tests__/react.react.test.tsx +811 -0
- package/src/__tests__/registry.test.ts +589 -0
- package/src/array.ts +335 -0
- package/src/compat.ts +294 -0
- package/src/index.ts +647 -0
- package/src/lifecycle.ts +580 -0
- package/src/map.ts +276 -0
- package/src/model.ts +832 -0
- package/src/primitives.ts +400 -0
- package/src/react.ts +626 -0
- package/src/registry.ts +741 -0
- package/src/tree.ts +1275 -0
- package/src/types.ts +520 -0
- package/src/undo.ts +566 -0
- package/src/utilities.ts +616 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Registry for Dynamic Model Registration
|
|
3
|
+
*
|
|
4
|
+
* Enables plugin architectures, code splitting, and lazy loading of models.
|
|
5
|
+
* Models can be registered at runtime and resolved dynamically.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // In a plugin or lazy-loaded module:
|
|
9
|
+
* registerModel("UserProfile", UserProfileModel);
|
|
10
|
+
*
|
|
11
|
+
* // In main app, before plugin loads:
|
|
12
|
+
* const LazyUserProfile = types.lateModel("UserProfile");
|
|
13
|
+
*
|
|
14
|
+
* // Reference that resolves dynamically:
|
|
15
|
+
* const ref = types.dynamicReference("UserProfile", userId);
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
IAnyType,
|
|
20
|
+
IType,
|
|
21
|
+
IValidationContext,
|
|
22
|
+
IValidationResult,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import {
|
|
25
|
+
$treenode,
|
|
26
|
+
getStateTreeNode,
|
|
27
|
+
StateTreeNode,
|
|
28
|
+
resolveIdentifier,
|
|
29
|
+
} from "./tree";
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Model Registry
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/** Registered model entry */
|
|
36
|
+
interface ModelEntry {
|
|
37
|
+
type: IAnyType;
|
|
38
|
+
/** Optional metadata for the model */
|
|
39
|
+
metadata?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pending resolution callbacks for models not yet registered */
|
|
43
|
+
interface PendingResolution {
|
|
44
|
+
resolve: (type: IAnyType) => void;
|
|
45
|
+
reject: (error: Error) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The global model registry */
|
|
49
|
+
const modelRegistry = new Map<string, ModelEntry>();
|
|
50
|
+
|
|
51
|
+
/** Pending resolutions for models that haven't been registered yet */
|
|
52
|
+
const pendingResolutions = new Map<string, PendingResolution[]>();
|
|
53
|
+
|
|
54
|
+
/** Listeners for model registration events */
|
|
55
|
+
const registrationListeners = new Set<(name: string, type: IAnyType) => void>();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register a model type with a name for dynamic resolution.
|
|
59
|
+
*
|
|
60
|
+
* @param name - Unique name for the model
|
|
61
|
+
* @param type - The model type to register
|
|
62
|
+
* @param metadata - Optional metadata to associate with the model
|
|
63
|
+
* @throws Error if a model with the same name is already registered
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* const UserModel = types.model("User", { id: types.identifier, name: types.string });
|
|
67
|
+
* registerModel("User", UserModel);
|
|
68
|
+
*
|
|
69
|
+
* // Later, in a plugin:
|
|
70
|
+
* registerModel("UserProfile", types.model("UserProfile", {
|
|
71
|
+
* user: types.reference(resolveModel("User")),
|
|
72
|
+
* bio: types.string
|
|
73
|
+
* }));
|
|
74
|
+
*/
|
|
75
|
+
export function registerModel(
|
|
76
|
+
name: string,
|
|
77
|
+
type: IAnyType,
|
|
78
|
+
metadata?: Record<string, unknown>,
|
|
79
|
+
): void {
|
|
80
|
+
if (modelRegistry.has(name)) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[jotai-state-tree] Model "${name}" is already registered. ` +
|
|
83
|
+
`Use unregisterModel() first if you want to replace it.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
modelRegistry.set(name, { type, metadata });
|
|
88
|
+
|
|
89
|
+
// Resolve any pending resolutions
|
|
90
|
+
const pending = pendingResolutions.get(name);
|
|
91
|
+
if (pending) {
|
|
92
|
+
pending.forEach(({ resolve }) => resolve(type));
|
|
93
|
+
pendingResolutions.delete(name);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Notify listeners
|
|
97
|
+
registrationListeners.forEach((listener) => listener(name, type));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Unregister a model type.
|
|
102
|
+
*
|
|
103
|
+
* @param name - Name of the model to unregister
|
|
104
|
+
* @returns true if the model was unregistered, false if it wasn't registered
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* unregisterModel("UserProfile");
|
|
108
|
+
*/
|
|
109
|
+
export function unregisterModel(name: string): boolean {
|
|
110
|
+
return modelRegistry.delete(name);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if a model is registered.
|
|
115
|
+
*
|
|
116
|
+
* @param name - Name of the model to check
|
|
117
|
+
* @returns true if the model is registered
|
|
118
|
+
*/
|
|
119
|
+
export function isModelRegistered(name: string): boolean {
|
|
120
|
+
return modelRegistry.has(name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve a model type by name (synchronous).
|
|
125
|
+
*
|
|
126
|
+
* @param name - Name of the model to resolve
|
|
127
|
+
* @returns The registered model type
|
|
128
|
+
* @throws Error if the model is not registered
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* const UserModel = resolveModel("User");
|
|
132
|
+
* const instance = UserModel.create({ id: "1", name: "John" });
|
|
133
|
+
*/
|
|
134
|
+
export function resolveModel<T extends IAnyType = IAnyType>(name: string): T {
|
|
135
|
+
const entry = modelRegistry.get(name);
|
|
136
|
+
if (!entry) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[jotai-state-tree] Model "${name}" is not registered. ` +
|
|
139
|
+
`Make sure to call registerModel("${name}", YourModelType) before resolving.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return entry.type as T;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Try to resolve a model type by name (returns undefined if not registered).
|
|
147
|
+
*
|
|
148
|
+
* @param name - Name of the model to resolve
|
|
149
|
+
* @returns The registered model type or undefined
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* const UserModel = tryResolveModel("User");
|
|
153
|
+
* if (UserModel) {
|
|
154
|
+
* const instance = UserModel.create({ id: "1", name: "John" });
|
|
155
|
+
* }
|
|
156
|
+
*/
|
|
157
|
+
export function tryResolveModel<T extends IAnyType = IAnyType>(
|
|
158
|
+
name: string,
|
|
159
|
+
): T | undefined {
|
|
160
|
+
return modelRegistry.get(name)?.type as T | undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Resolve a model type by name (asynchronous).
|
|
165
|
+
* Waits for the model to be registered if it isn't already.
|
|
166
|
+
*
|
|
167
|
+
* @param name - Name of the model to resolve
|
|
168
|
+
* @param timeout - Optional timeout in milliseconds (default: 30000)
|
|
169
|
+
* @returns Promise that resolves to the model type
|
|
170
|
+
* @throws Error if timeout is reached
|
|
171
|
+
*
|
|
172
|
+
* @example
|
|
173
|
+
* // Wait for a plugin to register its model
|
|
174
|
+
* const UserProfileModel = await resolveModelAsync("UserProfile");
|
|
175
|
+
*/
|
|
176
|
+
export function resolveModelAsync<T extends IAnyType = IAnyType>(
|
|
177
|
+
name: string,
|
|
178
|
+
timeout: number = 30000,
|
|
179
|
+
): Promise<T> {
|
|
180
|
+
// Check if already registered
|
|
181
|
+
const entry = modelRegistry.get(name);
|
|
182
|
+
if (entry) {
|
|
183
|
+
return Promise.resolve(entry.type as T);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create pending resolution
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const timeoutId = setTimeout(() => {
|
|
189
|
+
// Remove from pending
|
|
190
|
+
const pending = pendingResolutions.get(name);
|
|
191
|
+
if (pending) {
|
|
192
|
+
const index = pending.findIndex((p) => p.resolve === resolve);
|
|
193
|
+
if (index >= 0) {
|
|
194
|
+
pending.splice(index, 1);
|
|
195
|
+
if (pending.length === 0) {
|
|
196
|
+
pendingResolutions.delete(name);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
reject(
|
|
201
|
+
new Error(
|
|
202
|
+
`[jotai-state-tree] Timeout waiting for model "${name}" to be registered`,
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
}, timeout);
|
|
206
|
+
|
|
207
|
+
const wrappedResolve = (type: IAnyType) => {
|
|
208
|
+
clearTimeout(timeoutId);
|
|
209
|
+
resolve(type as T);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const wrappedReject = (error: Error) => {
|
|
213
|
+
clearTimeout(timeoutId);
|
|
214
|
+
reject(error);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (!pendingResolutions.has(name)) {
|
|
218
|
+
pendingResolutions.set(name, []);
|
|
219
|
+
}
|
|
220
|
+
pendingResolutions.get(name)!.push({
|
|
221
|
+
resolve: wrappedResolve,
|
|
222
|
+
reject: wrappedReject,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get metadata for a registered model.
|
|
229
|
+
*
|
|
230
|
+
* @param name - Name of the model
|
|
231
|
+
* @returns The metadata or undefined
|
|
232
|
+
*/
|
|
233
|
+
export function getModelMetadata(
|
|
234
|
+
name: string,
|
|
235
|
+
): Record<string, unknown> | undefined {
|
|
236
|
+
return modelRegistry.get(name)?.metadata;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get all registered model names.
|
|
241
|
+
*
|
|
242
|
+
* @returns Array of registered model names
|
|
243
|
+
*/
|
|
244
|
+
export function getRegisteredModelNames(): string[] {
|
|
245
|
+
return Array.from(modelRegistry.keys());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Listen for model registration events.
|
|
250
|
+
*
|
|
251
|
+
* @param listener - Callback invoked when a model is registered
|
|
252
|
+
* @returns Disposer function to remove the listener
|
|
253
|
+
*
|
|
254
|
+
* @example
|
|
255
|
+
* const disposer = onModelRegistered((name, type) => {
|
|
256
|
+
* console.log(`Model ${name} was registered`);
|
|
257
|
+
* });
|
|
258
|
+
* // Later:
|
|
259
|
+
* disposer();
|
|
260
|
+
*/
|
|
261
|
+
export function onModelRegistered(
|
|
262
|
+
listener: (name: string, type: IAnyType) => void,
|
|
263
|
+
): () => void {
|
|
264
|
+
registrationListeners.add(listener);
|
|
265
|
+
return () => {
|
|
266
|
+
registrationListeners.delete(listener);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Clear all registered models.
|
|
272
|
+
* Primarily useful for testing.
|
|
273
|
+
*/
|
|
274
|
+
export function clearModelRegistry(): void {
|
|
275
|
+
modelRegistry.clear();
|
|
276
|
+
// Reject any pending resolutions
|
|
277
|
+
pendingResolutions.forEach((pending, name) => {
|
|
278
|
+
pending.forEach(({ reject }) => {
|
|
279
|
+
reject(
|
|
280
|
+
new Error(
|
|
281
|
+
`[jotai-state-tree] Model registry was cleared while waiting for "${name}"`,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
pendingResolutions.clear();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// Late Model Type (Registry-based lazy resolution)
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Late model type that resolves from the registry.
|
|
295
|
+
* Unlike types.late(), this resolves by name from the global registry,
|
|
296
|
+
* allowing models to be registered after the type is defined.
|
|
297
|
+
*/
|
|
298
|
+
class LateModelType<T extends IAnyType>
|
|
299
|
+
implements
|
|
300
|
+
IType<
|
|
301
|
+
T extends IType<infer C, unknown, unknown> ? C : unknown,
|
|
302
|
+
T extends IType<unknown, infer S, unknown> ? S : unknown,
|
|
303
|
+
T extends IType<unknown, unknown, infer I> ? I : unknown
|
|
304
|
+
>
|
|
305
|
+
{
|
|
306
|
+
readonly _kind = "lateModel" as const;
|
|
307
|
+
readonly _modelName: string;
|
|
308
|
+
readonly name: string;
|
|
309
|
+
|
|
310
|
+
readonly _C!: T extends IType<infer C, unknown, unknown> ? C : unknown;
|
|
311
|
+
readonly _S!: T extends IType<unknown, infer S, unknown> ? S : unknown;
|
|
312
|
+
readonly _T!: T extends IType<unknown, unknown, infer I> ? I : unknown;
|
|
313
|
+
|
|
314
|
+
private _resolvedType?: T;
|
|
315
|
+
|
|
316
|
+
constructor(modelName: string) {
|
|
317
|
+
this._modelName = modelName;
|
|
318
|
+
this.name = `lateModel("${modelName}")`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private getType(): T {
|
|
322
|
+
if (!this._resolvedType) {
|
|
323
|
+
this._resolvedType = resolveModel<T>(this._modelName);
|
|
324
|
+
}
|
|
325
|
+
return this._resolvedType;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
create(
|
|
329
|
+
snapshot?: T extends IType<infer C, unknown, unknown> ? C : unknown,
|
|
330
|
+
env?: unknown,
|
|
331
|
+
): T extends IType<unknown, unknown, infer I> ? I : unknown {
|
|
332
|
+
return this.getType().create(snapshot, env) as T extends IType<
|
|
333
|
+
unknown,
|
|
334
|
+
unknown,
|
|
335
|
+
infer I
|
|
336
|
+
>
|
|
337
|
+
? I
|
|
338
|
+
: unknown;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
is(
|
|
342
|
+
value: unknown,
|
|
343
|
+
): value is T extends IType<unknown, unknown, infer I> ? I : unknown {
|
|
344
|
+
return this.getType().is(value);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
validate(value: unknown, context: IValidationContext[]): IValidationResult {
|
|
348
|
+
return this.getType().validate(value, context);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Create a late-resolving type that looks up the model from the registry.
|
|
354
|
+
* This allows you to reference models that may not be registered yet.
|
|
355
|
+
*
|
|
356
|
+
* @param modelName - Name of the model in the registry
|
|
357
|
+
* @returns A type that resolves from the registry when used
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* // Define a type that references a model not yet registered
|
|
361
|
+
* const PostStore = types.model("PostStore", {
|
|
362
|
+
* posts: types.array(types.lateModel("Post")),
|
|
363
|
+
* author: types.reference(types.lateModel("User"))
|
|
364
|
+
* });
|
|
365
|
+
*
|
|
366
|
+
* // Later, register the models (e.g., from a plugin)
|
|
367
|
+
* registerModel("Post", PostModel);
|
|
368
|
+
* registerModel("User", UserModel);
|
|
369
|
+
*
|
|
370
|
+
* // Now PostStore.create() will work
|
|
371
|
+
*/
|
|
372
|
+
export function lateModel<T extends IAnyType = IAnyType>(
|
|
373
|
+
modelName: string,
|
|
374
|
+
): IType<
|
|
375
|
+
T extends IType<infer C, unknown, unknown> ? C : unknown,
|
|
376
|
+
T extends IType<unknown, infer S, unknown> ? S : unknown,
|
|
377
|
+
T extends IType<unknown, unknown, infer I> ? I : unknown
|
|
378
|
+
> {
|
|
379
|
+
return new LateModelType<T>(modelName);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ============================================================================
|
|
383
|
+
// Dynamic Reference Type (with custom resolvers)
|
|
384
|
+
// ============================================================================
|
|
385
|
+
|
|
386
|
+
/** Options for dynamic references */
|
|
387
|
+
export interface DynamicReferenceOptions<T extends IAnyType> {
|
|
388
|
+
/**
|
|
389
|
+
* Custom getter to resolve the reference.
|
|
390
|
+
* If not provided, uses the default identifier-based resolution.
|
|
391
|
+
*
|
|
392
|
+
* @param identifier - The stored identifier value
|
|
393
|
+
* @param parent - The parent node containing the reference
|
|
394
|
+
* @returns The resolved instance or undefined
|
|
395
|
+
*/
|
|
396
|
+
get?: (
|
|
397
|
+
identifier: string | number,
|
|
398
|
+
parent: unknown,
|
|
399
|
+
) => (T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined;
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Custom setter to extract the identifier from a value.
|
|
403
|
+
* If not provided, uses the identifier property of the value.
|
|
404
|
+
*
|
|
405
|
+
* @param value - The value being set
|
|
406
|
+
* @param parent - The parent node containing the reference
|
|
407
|
+
* @returns The identifier to store
|
|
408
|
+
*/
|
|
409
|
+
set?: (
|
|
410
|
+
value: T extends IType<unknown, unknown, infer I> ? I : unknown,
|
|
411
|
+
parent: unknown,
|
|
412
|
+
) => string | number;
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Called when resolution fails.
|
|
416
|
+
* Can return a fallback value or throw an error.
|
|
417
|
+
*/
|
|
418
|
+
onInvalidated?: (
|
|
419
|
+
identifier: string | number,
|
|
420
|
+
parent: unknown,
|
|
421
|
+
) => (T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Dynamic reference type with custom resolution logic.
|
|
426
|
+
*/
|
|
427
|
+
class DynamicReferenceType<T extends IAnyType>
|
|
428
|
+
implements
|
|
429
|
+
IType<
|
|
430
|
+
string | number,
|
|
431
|
+
string | number,
|
|
432
|
+
T extends IType<unknown, unknown, infer I> ? I : unknown
|
|
433
|
+
>
|
|
434
|
+
{
|
|
435
|
+
readonly _kind = "dynamicReference" as const;
|
|
436
|
+
readonly _modelName: string;
|
|
437
|
+
readonly _options: DynamicReferenceOptions<T>;
|
|
438
|
+
readonly name: string;
|
|
439
|
+
|
|
440
|
+
readonly _C!: string | number;
|
|
441
|
+
readonly _S!: string | number;
|
|
442
|
+
readonly _T!: T extends IType<unknown, unknown, infer I> ? I : unknown;
|
|
443
|
+
|
|
444
|
+
constructor(modelName: string, options: DynamicReferenceOptions<T> = {}) {
|
|
445
|
+
this._modelName = modelName;
|
|
446
|
+
this._options = options;
|
|
447
|
+
this.name = `dynamicReference("${modelName}")`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
create(
|
|
451
|
+
snapshot?: string | number,
|
|
452
|
+
env?: unknown,
|
|
453
|
+
): T extends IType<unknown, unknown, infer I> ? I : unknown {
|
|
454
|
+
if (snapshot === undefined || snapshot === null) {
|
|
455
|
+
throw new Error(
|
|
456
|
+
`[jotai-state-tree] Cannot create dynamicReference with undefined/null identifier`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Create a proxy that resolves the reference on access
|
|
461
|
+
const self = this;
|
|
462
|
+
|
|
463
|
+
// For dynamic references, we return a getter-based proxy
|
|
464
|
+
// The actual resolution happens when properties are accessed
|
|
465
|
+
type InstanceType =
|
|
466
|
+
T extends IType<unknown, unknown, infer I> ? I : unknown;
|
|
467
|
+
const referenceProxy = new Proxy({} as object, {
|
|
468
|
+
get(_target, prop) {
|
|
469
|
+
// Resolve the actual target
|
|
470
|
+
const resolved = self.resolveReference(snapshot, null);
|
|
471
|
+
if (!resolved) {
|
|
472
|
+
if (self._options.onInvalidated) {
|
|
473
|
+
const fallback = self._options.onInvalidated(snapshot, null);
|
|
474
|
+
if (fallback) {
|
|
475
|
+
return (fallback as Record<string | symbol, unknown>)[prop];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
throw new Error(
|
|
479
|
+
`[jotai-state-tree] Failed to resolve dynamicReference("${self._modelName}") with identifier "${snapshot}"`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return (resolved as Record<string | symbol, unknown>)[prop];
|
|
483
|
+
},
|
|
484
|
+
has(_target, prop) {
|
|
485
|
+
const resolved = self.resolveReference(snapshot, null);
|
|
486
|
+
if (!resolved) return false;
|
|
487
|
+
return prop in (resolved as object);
|
|
488
|
+
},
|
|
489
|
+
ownKeys(_target) {
|
|
490
|
+
const resolved = self.resolveReference(snapshot, null);
|
|
491
|
+
if (!resolved) return [];
|
|
492
|
+
return Reflect.ownKeys(resolved as object);
|
|
493
|
+
},
|
|
494
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
495
|
+
const resolved = self.resolveReference(snapshot, null);
|
|
496
|
+
if (!resolved) return undefined;
|
|
497
|
+
return Object.getOwnPropertyDescriptor(resolved as object, prop);
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return referenceProxy as InstanceType;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private resolveReference(
|
|
505
|
+
identifier: string | number,
|
|
506
|
+
parent: unknown,
|
|
507
|
+
): (T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined {
|
|
508
|
+
// Use custom getter if provided
|
|
509
|
+
if (this._options.get) {
|
|
510
|
+
return this._options.get(identifier, parent);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Default: resolve from identifier registry
|
|
514
|
+
try {
|
|
515
|
+
const targetType = tryResolveModel(this._modelName);
|
|
516
|
+
if (!targetType) {
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
return resolveIdentifier(this._modelName, identifier) as T extends IType<
|
|
520
|
+
unknown,
|
|
521
|
+
unknown,
|
|
522
|
+
infer I
|
|
523
|
+
>
|
|
524
|
+
? I
|
|
525
|
+
: unknown;
|
|
526
|
+
} catch {
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
is(
|
|
532
|
+
value: unknown,
|
|
533
|
+
): value is T extends IType<unknown, unknown, infer I> ? I : unknown {
|
|
534
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
535
|
+
return true; // Identifiers are valid
|
|
536
|
+
}
|
|
537
|
+
// Check if it's an instance of the target type
|
|
538
|
+
const targetType = tryResolveModel(this._modelName);
|
|
539
|
+
if (targetType) {
|
|
540
|
+
return targetType.is(value);
|
|
541
|
+
}
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
validate(value: unknown, context: IValidationContext[]): IValidationResult {
|
|
546
|
+
if (value === undefined || value === null) {
|
|
547
|
+
return {
|
|
548
|
+
valid: false,
|
|
549
|
+
errors: [
|
|
550
|
+
{
|
|
551
|
+
context,
|
|
552
|
+
value,
|
|
553
|
+
message: "Reference identifier cannot be undefined or null",
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
560
|
+
return {
|
|
561
|
+
valid: false,
|
|
562
|
+
errors: [
|
|
563
|
+
{
|
|
564
|
+
context,
|
|
565
|
+
value,
|
|
566
|
+
message: "Reference identifier must be a string or number",
|
|
567
|
+
},
|
|
568
|
+
],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return { valid: true, errors: [] };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Create a dynamic reference that resolves from the model registry.
|
|
578
|
+
* Supports custom get/set resolvers for advanced use cases like API fetching.
|
|
579
|
+
*
|
|
580
|
+
* @param modelName - Name of the model in the registry
|
|
581
|
+
* @param options - Optional custom resolution options
|
|
582
|
+
* @returns A reference type
|
|
583
|
+
*
|
|
584
|
+
* @example
|
|
585
|
+
* // Basic usage - resolves from registry
|
|
586
|
+
* const PostStore = types.model("PostStore", {
|
|
587
|
+
* author: types.dynamicReference("User")
|
|
588
|
+
* });
|
|
589
|
+
*
|
|
590
|
+
* // With custom resolver (e.g., API fetching)
|
|
591
|
+
* const PostStore = types.model("PostStore", {
|
|
592
|
+
* author: types.dynamicReference("User", {
|
|
593
|
+
* get(id, parent) {
|
|
594
|
+
* return userCache.get(id) ?? fetchUserSync(id);
|
|
595
|
+
* },
|
|
596
|
+
* set(user) {
|
|
597
|
+
* return user.id;
|
|
598
|
+
* },
|
|
599
|
+
* onInvalidated(id) {
|
|
600
|
+
* console.warn(`User ${id} not found`);
|
|
601
|
+
* return undefined;
|
|
602
|
+
* }
|
|
603
|
+
* })
|
|
604
|
+
* });
|
|
605
|
+
*/
|
|
606
|
+
export function dynamicReference<T extends IAnyType = IAnyType>(
|
|
607
|
+
modelName: string,
|
|
608
|
+
options: DynamicReferenceOptions<T> = {},
|
|
609
|
+
): IType<
|
|
610
|
+
string | number,
|
|
611
|
+
string | number,
|
|
612
|
+
T extends IType<unknown, unknown, infer I> ? I : unknown
|
|
613
|
+
> {
|
|
614
|
+
return new DynamicReferenceType<T>(modelName, options);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ============================================================================
|
|
618
|
+
// Safe Dynamic Reference Type
|
|
619
|
+
// ============================================================================
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Safe dynamic reference that returns undefined instead of throwing.
|
|
623
|
+
*/
|
|
624
|
+
class SafeDynamicReferenceType<T extends IAnyType>
|
|
625
|
+
implements
|
|
626
|
+
IType<
|
|
627
|
+
string | number | undefined,
|
|
628
|
+
string | number | undefined,
|
|
629
|
+
(T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined
|
|
630
|
+
>
|
|
631
|
+
{
|
|
632
|
+
readonly _kind = "safeDynamicReference" as const;
|
|
633
|
+
readonly _modelName: string;
|
|
634
|
+
readonly _options: DynamicReferenceOptions<T>;
|
|
635
|
+
readonly name: string;
|
|
636
|
+
|
|
637
|
+
readonly _C!: string | number | undefined;
|
|
638
|
+
readonly _S!: string | number | undefined;
|
|
639
|
+
readonly _T!:
|
|
640
|
+
| (T extends IType<unknown, unknown, infer I> ? I : unknown)
|
|
641
|
+
| undefined;
|
|
642
|
+
|
|
643
|
+
constructor(modelName: string, options: DynamicReferenceOptions<T> = {}) {
|
|
644
|
+
this._modelName = modelName;
|
|
645
|
+
this._options = options;
|
|
646
|
+
this.name = `safeDynamicReference("${modelName}")`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
create(
|
|
650
|
+
snapshot?: string | number,
|
|
651
|
+
env?: unknown,
|
|
652
|
+
): (T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined {
|
|
653
|
+
if (snapshot === undefined || snapshot === null) {
|
|
654
|
+
return undefined;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Try to resolve, return undefined if not found
|
|
658
|
+
try {
|
|
659
|
+
if (this._options.get) {
|
|
660
|
+
return this._options.get(snapshot, null);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const targetType = tryResolveModel(this._modelName);
|
|
664
|
+
if (!targetType) {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
return resolveIdentifier(this._modelName, snapshot) as T extends IType<
|
|
668
|
+
unknown,
|
|
669
|
+
unknown,
|
|
670
|
+
infer I
|
|
671
|
+
>
|
|
672
|
+
? I
|
|
673
|
+
: unknown;
|
|
674
|
+
} catch {
|
|
675
|
+
if (this._options.onInvalidated) {
|
|
676
|
+
return this._options.onInvalidated(snapshot, null);
|
|
677
|
+
}
|
|
678
|
+
return undefined;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
is(
|
|
683
|
+
value: unknown,
|
|
684
|
+
): value is
|
|
685
|
+
| (T extends IType<unknown, unknown, infer I> ? I : unknown)
|
|
686
|
+
| undefined {
|
|
687
|
+
if (value === undefined) return true;
|
|
688
|
+
if (typeof value === "string" || typeof value === "number") return true;
|
|
689
|
+
const targetType = tryResolveModel(this._modelName);
|
|
690
|
+
if (targetType) {
|
|
691
|
+
return targetType.is(value);
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
validate(value: unknown, context: IValidationContext[]): IValidationResult {
|
|
697
|
+
if (value === undefined || value === null) {
|
|
698
|
+
return { valid: true, errors: [] };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
702
|
+
return {
|
|
703
|
+
valid: false,
|
|
704
|
+
errors: [
|
|
705
|
+
{
|
|
706
|
+
context,
|
|
707
|
+
value,
|
|
708
|
+
message:
|
|
709
|
+
"Reference identifier must be a string, number, or undefined",
|
|
710
|
+
},
|
|
711
|
+
],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return { valid: true, errors: [] };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Create a safe dynamic reference that returns undefined instead of throwing.
|
|
721
|
+
*
|
|
722
|
+
* @param modelName - Name of the model in the registry
|
|
723
|
+
* @param options - Optional custom resolution options
|
|
724
|
+
* @returns A safe reference type
|
|
725
|
+
*/
|
|
726
|
+
export function safeDynamicReference<T extends IAnyType = IAnyType>(
|
|
727
|
+
modelName: string,
|
|
728
|
+
options: DynamicReferenceOptions<T> = {},
|
|
729
|
+
): IType<
|
|
730
|
+
string | number | undefined,
|
|
731
|
+
string | number | undefined,
|
|
732
|
+
(T extends IType<unknown, unknown, infer I> ? I : unknown) | undefined
|
|
733
|
+
> {
|
|
734
|
+
return new SafeDynamicReferenceType<T>(modelName, options);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ============================================================================
|
|
738
|
+
// Type Exports
|
|
739
|
+
// ============================================================================
|
|
740
|
+
|
|
741
|
+
export type { ModelEntry };
|