UNPKG

@mobx-sentinel/core

Version:

MobX library for non-intrusive class-based model enhancement. Acting as a sentinel, it provides change detection, reactive validation, and form integration capabilities without contamination.

1 lines 80.3 kB
{"version":3,"sources":["../src/nested.ts","../src/decorator.ts","../src/annotationProcessor.ts","../src/keyPath.ts","../src/mobx-utils.ts","../src/watcher.ts","../src/validator.ts","../src/error.ts","../src/asyncJob.ts"],"sourcesContent":["import { comparer, computed, makeObservable } from \"mobx\";\nimport { createPropertyLikeAnnotation, getAnnotationProcessor } from \"./annotationProcessor\";\nimport { KeyPath } from \"./keyPath\";\nimport { unwrapShallowContents } from \"./mobx-utils\";\n\nenum NestedMode {\n /**\n * Nested objects are nested under a new key.\n */\n Default = \"@nested\",\n /**\n * Nested objects are hoisted to the parent object.\n */\n Hoist = \"@nested.hoist\",\n}\n\nconst nestedKey = Symbol(\"nested\");\nconst createNested = createPropertyLikeAnnotation(nestedKey, () => NestedMode.Default);\nconst createNestedHoist = createPropertyLikeAnnotation(nestedKey, () => NestedMode.Hoist);\n\n/**\n * Annotation for nested objects\n *\n * @remarks\n * - Mixed nested modes for the same key are not allowed\n *\n * @function\n */\nexport const nested = Object.assign(createNested, {\n /**\n * Annotation for nested objects that should be hoisted to the parent object\n *\n * When using hoist mode:\n * - Nested objects are treated as part of the parent object\n * - Changes and errors from nested objects appear on the parent\n *\n * @remarks\n * - Multiple hoisted keys within the same inheritance chain are not allowed\n *\n * @example\n * ```typescript\n * @nested.hoist @observable private list: Sample[] = [];\n * // Key path for \"list.0.value\" becomes \"0.value\"\n * ```\n *\n * @function\n */\n hoist: createNestedHoist,\n});\n\n/**\n * Get all `@nested` annotations from the target object\n */\nexport function* getNestedAnnotations(target: object): Generator<{\n key: string | symbol;\n getValue: () => any;\n hoist: boolean;\n}> {\n const processor = getAnnotationProcessor(target);\n if (!processor) return;\n\n const annotations = processor.getPropertyLike(nestedKey);\n if (!annotations) return;\n\n let hoistedKey: string | symbol | null = null;\n for (const [key, metadata] of annotations) {\n const modes = new Set<NestedMode>(metadata.data);\n if (modes.size > 1) {\n throw new Error(`Mixed @nested annotations are not allowed for the same key: ${String(key)}`);\n }\n const hoist = modes.has(NestedMode.Hoist);\n if (hoist) {\n if (hoistedKey) {\n throw new Error(\n `Multiple @nested.hoist annotations are not allowed in the same class: ${String(hoistedKey)} and ${String(key)}`\n );\n }\n hoistedKey = key;\n }\n const getValue = () => (key in target ? (target as any)[key] : metadata.get?.());\n yield { key, getValue, hoist };\n }\n}\n\n/**\n * A fetcher that returns all nested entries\n *\n * Key features:\n * - Tracks nested objects in properties, arrays, sets, and maps\n * - Provides access to nested objects by key path\n * - Supports iteration over all nested objects\n * - Maintains parent-child relationships\n *\n * @remarks\n * Symbol keys are not supported\n */\nexport class StandardNestedFetcher<T extends object> implements Iterable<StandardNestedFetcher.Entry<T>> {\n readonly #transform: (entry: StandardNestedFetcher.Entry<any>) => T | null;\n readonly #fetchers = new Map<KeyPath, () => Generator<StandardNestedFetcher.Entry<T>>>();\n\n /**\n * @param target - The target object\n * @param transform - A function that transforms the entry to the desired type.\\\n * If the function returns `null`, the entry is ignored.\n */\n constructor(target: object, transform: (entry: StandardNestedFetcher.Entry<any>) => T | null) {\n makeObservable(this);\n\n this.#transform = transform;\n\n for (const { key, getValue, hoist } of getNestedAnnotations(target)) {\n if (typeof key !== \"string\") continue; // symbol keys are not supported\n const keyPath = hoist ? KeyPath.Self : KeyPath.build(key);\n const fetcher = this.#createFetcher(keyPath, getValue);\n this.#fetchers.set(keyPath, fetcher);\n }\n }\n\n #createFetcher(key: KeyPath, getValue: () => any) {\n // eslint-disable-next-line @typescript-eslint/no-this-alias\n const that = this;\n return function* (): Generator<StandardNestedFetcher.Entry<T>> {\n for (const [subKey, value] of unwrapShallowContents(getValue())) {\n if (typeof subKey === \"symbol\") continue; // symbol keys are not supported\n const keyPath = KeyPath.build(key, subKey);\n const data = that.#transform({ key, keyPath, data: value }) ?? null;\n if (data === null) continue;\n yield { key, keyPath, data };\n }\n };\n }\n\n /** Iterate over all entries */\n *[Symbol.iterator]() {\n for (const fn of this.#fetchers.values()) {\n for (const entry of fn()) {\n yield entry;\n }\n }\n }\n\n /** Iterate over all entries for the given key path */\n *getForKey(keyPath: KeyPath) {\n const fetcher = this.#fetchers.get(keyPath);\n if (!fetcher) return;\n for (const entry of fetcher()) {\n yield entry;\n }\n }\n\n /** Map of key paths to data */\n @computed({ equals: comparer.shallow })\n get dataMap(): ReadonlyMap<KeyPath, T> {\n const result = new Map<KeyPath, T>();\n for (const entry of this) {\n result.set(entry.keyPath, entry.data);\n }\n return result;\n }\n}\n\nexport namespace StandardNestedFetcher {\n /**\n * Entry representing a nested object\n *\n * @remarks\n * Used when iterating over nested objects to provide context about their location\n */\n export type Entry<T extends object> = {\n /** Name of the field containing the nested object */\n readonly key: KeyPath;\n /** Full path to the nested object */\n readonly keyPath: KeyPath;\n /** Data */\n readonly data: T;\n };\n}\n","export namespace Decorator202112 {\n /* eslint-disable @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-wrapper-object-types */\n export type ClassDecorator<TFunction extends Function> = (target: TFunction) => TFunction | void;\n export type PropertyDecorator<TObject extends Object> = (target: TObject, propertyKey: string | symbol) => void;\n export type MethodDecorator<TObject extends Object, T> = (\n target: TObject,\n propertyKey: string | symbol,\n descriptor: TypedPropertyDescriptor<T>\n ) => TypedPropertyDescriptor<T> | void;\n export type ParameterDecorator<TObject extends Object> = (\n target: TObject,\n propertyKey: string | symbol | undefined,\n parameterIndex: number\n ) => void;\n /* eslint-enable @typescript-eslint/no-unsafe-function-type, @typescript-eslint/no-wrapper-object-types */\n}\n\nexport namespace Decorator202203 {\n export type ClassDecorator<Value extends abstract new (...args: any) => any = abstract new (...args: any) => any> = (\n value: Value,\n context: ClassDecoratorContext<Value>\n ) => Value | void;\n export type ClassAccessorDecorator<This = any, Value = any> = (\n value: ClassAccessorDecoratorTarget<This, Value>,\n context: ClassAccessorDecoratorContext\n ) => ClassAccessorDecoratorResult<This, Value> | void;\n export type ClassGetterDecorator<This = any, Value = any> = (\n value: (this: This) => Value,\n context: ClassGetterDecoratorContext\n ) => ((this: This) => Value) | void;\n export type ClassSetterDecorator<This = any, Value = any> = (\n value: (this: This, value: Value) => void,\n context: ClassSetterDecoratorContext\n ) => ((this: This, value: Value) => void) | void;\n export type ClassMethodDecorator<This = any, Value extends (...p: any[]) => any = any> = (\n value: Value,\n context: ClassMethodDecoratorContext<This, Value>\n ) => Value | void;\n export type ClassFieldDecorator<This = any, Value extends (...p: any[]) => any = any> = (\n value: undefined,\n context: ClassFieldDecoratorContext<This, Value>\n ) => Value | void;\n}\n\nexport function isDecorator202203(context: any): context is DecoratorContext {\n return typeof context == \"object\" && typeof context[\"kind\"] == \"string\";\n}\n\nexport function isDecorator202112(context: any): context is string | symbol {\n return typeof context == \"string\" || typeof context == \"symbol\";\n}\n","import { Decorator202112, Decorator202203, isDecorator202112, isDecorator202203 } from \"./decorator\";\n\n/**\n * Processor for handling property-like annotations\n *\n * Key features:\n * - Supports both stage2 and stage3 decorators\n * - Handles inheritance correctly - annotations from parent classes are preserved\n * - Supports property overrides in child classes\n * - Supports private fields and methods\n */\nexport class AnnotationProcessor {\n readonly #propertyLike = new Map<\n symbol,\n Map<\n string | symbol,\n {\n data: any[];\n get?: () => any;\n }\n >\n >();\n\n /**\n * Register a property-like annotation\n *\n * @remarks\n * - Multiple annotations can be registered for the same property\n * - When a property is overridden in a child class, both parent and child annotations are preserved\n */\n registerPropertyLike(\n annotationKey: symbol,\n args: {\n propertyKey: string | symbol;\n data: any;\n get?: () => any;\n }\n ) {\n let annotations = this.#propertyLike.get(annotationKey);\n if (!annotations) {\n annotations = new Map();\n this.#propertyLike.set(annotationKey, annotations);\n }\n\n let propertyMetadata = annotations.get(args.propertyKey);\n if (!propertyMetadata) {\n propertyMetadata = { data: [], get: args.get };\n annotations.set(args.propertyKey, propertyMetadata);\n }\n\n propertyMetadata.data.push(args.data);\n }\n\n /**\n * Get all registered property-like annotations\n *\n * @returns Map of property keys to their metadata, or undefined if no annotations exist\n */\n getPropertyLike(annotationKey: symbol) {\n return this.#propertyLike.get(annotationKey);\n }\n\n /**\n * Clone the annotation processor\n *\n * Used when inheriting annotations from parent classes\n */\n clone() {\n const clone = new AnnotationProcessor();\n for (const [annotationKey, properties] of this.#propertyLike) {\n for (const [propertyKey, propertyMetadata] of properties) {\n for (const data of propertyMetadata.data) {\n clone.registerPropertyLike(annotationKey, {\n propertyKey,\n data,\n get: propertyMetadata.get,\n });\n }\n }\n }\n return clone;\n }\n}\n\nconst store = new WeakMap<object, AnnotationProcessor>();\n\nfunction getStored(target: object): { processor: AnnotationProcessor | null; isOwn: boolean } {\n let isOwn = true;\n while (target && typeof target === \"object\") {\n const processor = store.get(target);\n if (processor) {\n return { processor, isOwn };\n }\n isOwn = false;\n target = Object.getPrototypeOf(target);\n }\n return { processor: null, isOwn };\n}\n\nfunction createStored(target: object, clone: boolean) {\n const s = getStored(target);\n if (!s.processor) {\n s.processor = new AnnotationProcessor();\n store.set(target, s.processor);\n } else if (clone && !s.isOwn) {\n s.processor = s.processor.clone();\n store.set(target, s.processor);\n }\n return s.processor;\n}\n\n/**\n * Get the annotation processor for the target object\n *\n * @returns The annotation processor or null if not found\n */\nexport function getAnnotationProcessor(target: object) {\n return getStored(target).processor;\n}\n\n/**\n * Create a property-like annotation\n *\n * @remarks\n * - Stage2 annotations are processed at declaration time, not instantiation time\n * - Stage3 annotations are processed at instantiation time\n * - Supports both public and private class members\n * - Works with properties, getters, and class fields\n * - Auto accessors are not supported in stage2 decorators\n *\n * @param annotationKey - Unique symbol to identify this annotation type\n * @param getData - Function to generate annotation data for a property\n */\nexport function createPropertyLikeAnnotation<T extends object, Data>(\n annotationKey: symbol,\n getData: (propertyKey: string | symbol) => Data\n): Decorator202112.PropertyDecorator<T> &\n Decorator202203.ClassGetterDecorator<T> &\n Decorator202203.ClassAccessorDecorator<T> &\n Decorator202203.ClassFieldDecorator<T> {\n return (target, context) => {\n if (isDecorator202203(context)) {\n context.addInitializer(function () {\n const processor = createStored(this as T, false);\n processor.registerPropertyLike(annotationKey, {\n propertyKey: context.name,\n data: getData(context.name),\n get: () => context.access.get(this),\n });\n });\n } else if (target && isDecorator202112(context)) {\n const processor = createStored(target, true);\n processor.registerPropertyLike(annotationKey, {\n propertyKey: context,\n data: getData(context),\n });\n }\n };\n}\n","/**\n * Key paths represent paths to access nested properties in an object\n *\n * Key paths can be either:\n * - A dot-notation string representing nested properties (e.g. \"user.address.street\")\n * - A special Self symbol representing the current object\n */\nexport type KeyPath = KeyPath.Component | KeyPath.Self;\n\nexport namespace KeyPath {\n /** Branded type for key path components */\n export type Component = string & { __brand: \"KeyPath.Component\" };\n /** Branded type for a self-referencing key path */\n export type Self = symbol & { __brand: \"KeyPath.Self\" };\n /** Self path symbol */\n export const Self = Symbol(\"self\") as Self;\n\n /**\n * Whether a key path is a self path\n *\n * Empty strings are also considered self paths.\n */\n export function isSelf(keyPath: KeyPath): keyPath is Self {\n return keyPath === Self || keyPath === \"\";\n }\n\n /**\n * Build a key path from an array of keys\n *\n * @remarks\n * - Joins keys with dots\n * - Ignores null values\n * - Ignores empty strings\n * - Handles {@link KeyPath.Self}\n *\n * @returns The constructed key path\n */\n export function build(...keys: (KeyPath | string | number | null)[]): KeyPath {\n const keyPath = keys\n .flatMap((key) => {\n switch (typeof key) {\n case \"string\":\n if (key === \"\") return []; // ignores empty keys\n return [key];\n case \"number\":\n return [String(key)];\n case \"symbol\":\n return []; // ignores KeyPath.Self\n default:\n key satisfies null;\n return [];\n }\n })\n .join(\".\");\n if (keyPath === \"\") return Self;\n return keyPath as KeyPath;\n }\n\n /**\n * Get the relative key path from a prefix key path\n *\n * @returns\n * - `null` if the key path is not a child of the prefix\n * - {@link KeyPath.Self} if the paths are identical\n * - The original path if the prefix is {@link KeyPath.Self}\n */\n export function getRelative(keyPath: KeyPath, prefixKeyPath: KeyPath) {\n if (isSelf(keyPath)) return Self;\n if (isSelf(prefixKeyPath)) return keyPath;\n if (!`${keyPath}.`.startsWith(`${prefixKeyPath}.`)) return null;\n return build(keyPath.slice(prefixKeyPath.length + 1));\n }\n\n /**\n * Get the parent key of a key path\n *\n * @returns The parent key or a self path if the key path is a self path\n */\n export function getParentKey(keyPath: KeyPath): KeyPath {\n if (isSelf(keyPath)) return Self;\n const [parentKey] = keyPath.split(\".\", 1);\n return (parentKey as Component) || Self;\n }\n\n /**\n * Get all ancestors of a key path\n *\n * @param includeSelf Whether to include the path itself\n *\n * @returns\n * - {@link KeyPath.Self} for single-level paths when `includeSelf` is false\n * - Key paths starting from the closest ancestor and moving up to the root\n */\n export function* getAncestors(keyPath: KeyPath, includeSelf = true): Generator<KeyPath> {\n if (includeSelf) {\n yield keyPath || Self;\n }\n if (isSelf(keyPath)) {\n return;\n }\n const parts = keyPath.split(\".\");\n while (parts.length > 1) {\n parts.pop();\n yield build(...parts);\n }\n }\n}\n\n/**\n * Read-only interface for {@link KeyPathMultiMap}.\n */\nexport interface ReadonlyKeyPathMultiMap<T> extends Iterable<[KeyPath, T]> {\n /** The number of key paths */\n readonly size: number;\n /** Whether the map contains a key path */\n has(keyPath: KeyPath): boolean;\n /** Find exact matches for a key path */\n findExact(keyPath: KeyPath): Generator<T>;\n /** Find prefix matches for a key path */\n findPrefix(keyPath: KeyPath): Generator<T>;\n /** Get values for a key path */\n get(keyPath: KeyPath, prefixMatch?: boolean): Set<T>;\n}\n\n/**\n * Map to store multiple values with the same key path\n * with prefix matching support\n */\nexport class KeyPathMultiMap<T> implements ReadonlyKeyPathMultiMap<T> {\n /** Map to store key path -> values mapping */\n readonly #map = new Map<KeyPath, Set<T>>();\n /** Map to store key path -> child key paths mapping */\n readonly #prefixMap = new Map<KeyPath, Set<KeyPath>>();\n\n /** The number of key paths */\n get size() {\n return this.#map.size;\n }\n\n /** Whether the map contains a key path */\n has(keyPath: KeyPath, prefixMatch = false) {\n if (this.#map.has(keyPath)) {\n return true;\n }\n if (prefixMatch) {\n return this.#prefixMap.has(keyPath);\n }\n return false;\n }\n\n /** Find exact matches for a key path */\n *findExact(keyPath: KeyPath) {\n const values = this.#map.get(keyPath);\n if (values) {\n yield* values;\n }\n }\n\n /** Find prefix matches for a key path */\n *findPrefix(keyPath: KeyPath) {\n if (KeyPath.isSelf(keyPath)) {\n for (const values of this.#map.values()) {\n yield* values;\n }\n return;\n }\n\n const exactMatches = this.#map.get(keyPath);\n if (exactMatches) {\n yield* exactMatches;\n }\n\n const childPaths = this.#prefixMap.get(keyPath);\n if (childPaths) {\n for (const childPath of childPaths) {\n yield* this.findExact(childPath);\n }\n }\n }\n\n /** Get values for a key path */\n get(keyPath: KeyPath, prefixMatch = false): Set<T> {\n if (prefixMatch) {\n return new Set(this.findPrefix(keyPath));\n }\n return new Set(this.findExact(keyPath));\n }\n\n /** Add a value for a key path */\n set(keyPath: KeyPath, value: T): void {\n if (Object.isFrozen(this)) {\n throw new Error(\"Cannot modify frozen KeyPathMultiMap\");\n }\n\n // Add to main map\n let values = this.#map.get(keyPath);\n if (!values) {\n values = new Set();\n this.#map.set(keyPath, values);\n }\n values.add(value);\n\n // Update prefix map\n for (const ancestorKeyPath of KeyPath.getAncestors(keyPath, false)) {\n let children = this.#prefixMap.get(ancestorKeyPath);\n if (!children) {\n children = new Set();\n this.#prefixMap.set(ancestorKeyPath, children);\n }\n children.add(keyPath);\n }\n }\n\n /** Remove a key path */\n delete(keyPath: KeyPath): void {\n if (Object.isFrozen(this)) {\n throw new Error(\"Cannot modify frozen KeyPathMultiMap\");\n }\n\n // Remove from main map\n this.#map.delete(keyPath);\n\n // Update prefix map\n for (const ancestorKeyPath of KeyPath.getAncestors(keyPath, false)) {\n const children = this.#prefixMap.get(ancestorKeyPath);\n if (children) {\n children.delete(keyPath);\n if (children.size === 0) {\n this.#prefixMap.delete(ancestorKeyPath);\n }\n }\n }\n }\n\n /** Iterate over all values */\n *[Symbol.iterator](): IterableIterator<[KeyPath, T]> {\n for (const [keyPath, values] of this.#map.entries()) {\n for (const value of values) {\n yield [keyPath, value];\n }\n }\n }\n\n /** Create an immutable version of this map */\n toImmutable() {\n Object.freeze(this);\n return this as ReadonlyKeyPathMultiMap<T>;\n }\n}\n","import {\n isBoxedObservable,\n isObservableArray,\n isObservableSet,\n isObservableMap,\n isObservableObject,\n $mobx,\n} from \"mobx\";\nimport type { ObservableObjectAdministration } from \"mobx/dist/internal\";\n\n/**\n * Shallow read the content of the value if applicable,\n * in order to be included in reactions\n *\n * Supports:\n * - boxed observables\n * - observable arrays\n * - observable sets\n * - observable maps\n */\nexport function shallowReadValue(value: any) {\n if (isBoxedObservable(value)) {\n value = value.get();\n }\n\n if (isObservableArray(value)) {\n return value.slice();\n }\n if (isObservableSet(value)) {\n return new Set(value);\n }\n if (isObservableMap(value)) {\n return new Map(value);\n }\n\n return value;\n}\n\n/**\n * Unwrap shallow contents of the value if applicable\n *\n * Supports:\n * - boxed observables\n * - arrays and observable arrays\n * - sets and observable sets\n * - maps and observable maps\n */\nexport function* unwrapShallowContents(value: any): Generator<[key: string | symbol | number | null, content: any]> {\n if (isBoxedObservable(value)) {\n value = value.get();\n }\n\n if (Array.isArray(value) || isObservableArray(value)) {\n let i = 0;\n for (const element of value) {\n yield [i++, element];\n }\n return;\n }\n if (value instanceof Set || isObservableSet(value)) {\n let i = 0;\n for (const element of value) {\n yield [i++, element];\n }\n return;\n }\n if (value instanceof Map || isObservableMap(value)) {\n for (const [key, element] of value) {\n yield [key, element];\n }\n return;\n }\n yield [null, value];\n}\n\n/**\n * Get all MobX's `@observable` and `@computed` annotations from the target object\n *\n * Also includes their variants such as `@observable.ref` and `@computed.struct`.\n *\n * It relies on the internal API, so it may break in future versions of MobX.\\\n * When making changes, please ensure that the internal API is still available by runtime assertions.\n */\nexport function* getMobxObservableAnnotations(\n target: object\n): Generator<[key: string | symbol | number, getValue: () => any]> {\n if (!isObservableObject(target)) return;\n const adm = (target as any)[$mobx] as ObservableObjectAdministration;\n\n if (typeof adm !== \"object\" || !adm) return;\n if (!(\"values_\" in adm)) return;\n const values = adm.values_;\n if (!(values instanceof Map)) return;\n\n for (const [key, value] of values) {\n if (typeof key !== \"string\" && typeof key !== \"symbol\" && typeof key !== \"number\") continue;\n if (typeof value !== \"object\" || !value) continue;\n if (!(\"get\" in value && typeof value.get === \"function\")) continue;\n const getValue = () => (key in target ? (target as any)[key] : value.get());\n yield [key, getValue];\n }\n}\n","import { action, autorun, computed, makeObservable, observable, reaction, runInAction, transaction } from \"mobx\";\nimport { v4 as uuidV4 } from \"uuid\";\nimport { createPropertyLikeAnnotation, getAnnotationProcessor } from \"./annotationProcessor\";\nimport { getMobxObservableAnnotations, shallowReadValue, unwrapShallowContents } from \"./mobx-utils\";\nimport { StandardNestedFetcher, getNestedAnnotations } from \"./nested\";\nimport { KeyPath } from \"./keyPath\";\n\nenum WatchMode {\n /**\n * Watch values with identity comparison\n *\n * Akin to `@observable`.\n */\n Ref = \"@watch.ref\",\n /**\n * Watch shallow changes\n *\n * Akin to `@observable.shallow`.\n */\n Shallow = \"@watch\",\n}\n\nconst watchKey = Symbol(\"watch\");\nconst createWatch = createPropertyLikeAnnotation(watchKey, () => WatchMode.Shallow);\nconst createWatchRef = createPropertyLikeAnnotation(watchKey, () => WatchMode.Ref);\nconst unwatchKey = Symbol(\"unwatch\");\nconst createUnwatch = createPropertyLikeAnnotation(unwatchKey, () => true);\n\n/** Global state for controlling whether watching is enabled */\nlet unwatchStackCount = 0;\nfunction runInUnwatch(action: () => void): void {\n transaction(() => {\n ++unwatchStackCount;\n try {\n action();\n } finally {\n autorun(() => --unwatchStackCount);\n }\n });\n}\n\n/**\n * Annotation for watching changes to a property and a getter\n *\n * `@watch` by default unwraps boxed observables, arrays, sets, and maps — meaning it uses shallow comparison.\n * If you don't want this behavior, use `@watch.ref` instead.\n *\n * - `@observable` and `@computed` (and their variants) are automatically assumed to be `@watched`,\\\n * unless `@unwatch` or `@unwatch.ref` is specified.\n * - `@nested` (and its variants) are considered `@watched` unless `@unwatch` is specified.\n * - If `@watch` and `@watch.ref` are specified for the same key (in the same inheritance chain),\\\n * the last annotation prevails.\n *\n * @function\n */\nexport const watch = Object.freeze(\n Object.assign(createWatch, {\n /**\n * Annotation for watching values with identity comparison\n *\n * It has no effect when combined with `@nested`.\n *\n * @function\n */\n ref: createWatchRef,\n })\n);\n\n/**\n * Annotation for unwatching changes to a property and a getter\n *\n * When used as an annotation:\n * - Combine with `@observable`, `@computed` or `@nested` (and their variants) to stop watching changes.\n * - You cannot re-enable watching once `@unwatch` is specified.\n *\n * When used as a function:\n * - Runs a piece of code without changes being detected by Watcher.\n * ```typescript\n * unwatch(() => (model.field = \"value\"));\n * ```\n * - Warning: When used inside a transaction, it only becomes 'watching' when the outermost transaction completes.\n * ```typescript\n * runInAction(() => {\n * unwatch(() => {\n * // isWatching === false\n * });\n * // isWatching === FALSE <-- already in a transaction\n * unwatch(() => {\n * // isWatching === false\n * });\n * });\n * // isWatching === TRUE\n * ```\n *\n * @function\n */\nexport const unwatch: typeof runInUnwatch & typeof createUnwatch = (...args: any[]) => {\n if (args.length === 1 && typeof args[0] === \"function\") {\n return runInUnwatch(args[0]);\n }\n return createUnwatch(...(args as Parameters<typeof createUnwatch>));\n};\n\nconst watcherKey = Symbol(\"watcher\");\nconst internalToken = Symbol(\"watcher.internal\");\n\n/**\n * Watcher for tracking changes to observable properties\n *\n * - Automatically tracks `@observable` and `@computed` properties\n * - Supports `@watch` and `@watch.ref` annotations\n * - Can track nested objects\n * - Provides change detection at both property and path levels\n * - Can be temporarily disabled via `unwatch()`\n */\nexport class Watcher {\n readonly id = uuidV4();\n readonly #assumeChanged = observable.box(false);\n readonly #changedTick = observable.box(0n);\n readonly #changedKeys = observable.set<KeyPath>();\n readonly #processedKeys = new Set<string>();\n readonly #nestedFetcher: StandardNestedFetcher<Watcher>;\n\n /**\n * Get a watcher instance for the target object.\n *\n * @remarks\n * - Returns existing instance if one exists for the target\n * - Creates new instance if none exists\n * - Instances are cached and garbage collected with their targets\n *\n * @throws `TypeError` if the target is not an object.\n */\n static get<T extends object>(target: T): Watcher {\n const watcher = this.getSafe(target);\n if (!watcher) throw new TypeError(\"target: Expected an object\");\n return watcher;\n }\n\n /**\n * Get a watcher instance for the target object.\n *\n * Same as {@link Watcher.get} but returns null instead of throwing an error.\n */\n static getSafe(target: any): Watcher | null {\n if (!target || typeof target !== \"object\") {\n return null;\n }\n\n let watcher: Watcher | null = (target as any)[watcherKey] ?? null;\n if (!watcher) {\n watcher = new this(internalToken, target);\n Object.defineProperty(target, watcherKey, { value: watcher });\n }\n return watcher;\n }\n\n /** Whether Watcher is enabled in the current transaction */\n static get isWatching() {\n return unwatchStackCount === 0;\n }\n\n private constructor(token: symbol, target: object) {\n if (token !== internalToken) {\n throw new Error(\"private constructor\");\n }\n\n this.#nestedFetcher = new StandardNestedFetcher(target, (entry) => Watcher.getSafe(entry.data));\n this.#processUnwatchAnnotations(target);\n this.#processNestedAnnotations(target);\n this.#processWatchAnnotations(target);\n this.#processMobxAnnotations(target);\n\n makeObservable(this);\n }\n\n /**\n * The total number of changes processed\n *\n * Observe this value to react to changes.\n *\n * @remarks\n * - Incremented for each change and each affected key\n * - Not affected by assumeChanged()\n * - Reset to 0 when reset() is called\n */\n get changedTick() {\n return this.#changedTick.get();\n }\n\n /** Whether changes have been made */\n @computed\n get changed() {\n return this.changedTick > 0n || this.#assumeChanged.get();\n }\n\n /**\n * The keys that have changed\n *\n * @remarks\n * - Does not include keys of nested objects\n * - Cleared when reset() is called\n * - Updated when properties are modified\n */\n @computed.struct\n get changedKeys(): ReadonlySet<KeyPath> {\n return new Set(this.#changedKeys);\n }\n\n /**\n * The key paths that have changed\n *\n * Keys of nested objects are included.\n */\n @computed.struct\n get changedKeyPaths(): ReadonlySet<KeyPath> {\n const result = new Set(this.#changedKeys);\n for (const entry of this.#nestedFetcher) {\n for (const changedKeyPath of entry.data.changedKeyPaths) {\n result.add(KeyPath.build(entry.keyPath, changedKeyPath));\n }\n }\n return result;\n }\n\n /** Nested watchers */\n get nested() {\n return this.#nestedFetcher.dataMap;\n }\n\n /**\n * Reset the changed state\n *\n * @remarks\n * - Clears all changed keys\n * - Resets changedTick to 0\n * - Clears assumeChanged flag\n * - Resets all nested watchers\n */\n @action\n reset() {\n this.#changedKeys.clear();\n this.#changedTick.set(0n);\n this.#assumeChanged.set(false);\n\n for (const entry of this.#nestedFetcher) {\n entry.data.reset();\n }\n }\n\n /**\n * Assume some changes have been made\n *\n * It only changes {@link changed} to true and does not increment {@link changedTick}.\n */\n @action\n assumeChanged() {\n if (!Watcher.isWatching) return;\n this.#assumeChanged.set(true);\n }\n\n /** Mark a key as changed */\n #didChange(key: KeyPath) {\n if (!Watcher.isWatching) return;\n runInAction(() => {\n this.#changedKeys.add(key);\n this.#incrementChangedTick();\n });\n }\n\n /**\n * Increment the changed tick\n *\n * For when a key or key path is changed.\n */\n #incrementChangedTick() {\n if (!Watcher.isWatching) return;\n runInAction(() => {\n this.#changedTick.set(this.#changedTick.get() + 1n);\n });\n }\n\n /**\n * Process MobX's `@observable` and `@computed` annotations\n */\n #processMobxAnnotations(target: object) {\n for (const [key, getValue] of getMobxObservableAnnotations(target)) {\n if (typeof key !== \"string\") continue; // symbol and number keys are not supported\n if (this.#processedKeys.has(key)) continue;\n this.#processedKeys.add(key);\n\n reaction(\n () => shallowReadValue(getValue()),\n () => this.#didChange(KeyPath.build(key))\n );\n }\n }\n\n /**\n * Process `@nested` annotations\n */\n #processNestedAnnotations(target: object) {\n for (const { key, getValue, hoist } of getNestedAnnotations(target)) {\n if (typeof key !== \"string\") continue; // symbol and number keys are not supported\n if (this.#processedKeys.has(key)) continue;\n this.#processedKeys.add(key);\n\n reaction(\n () => shallowReadValue(getValue()),\n () => (hoist ? this.#incrementChangedTick() : this.#didChange(KeyPath.build(key)))\n );\n reaction(\n () => {\n let changed = false;\n for (const [, value] of unwrapShallowContents(getValue())) {\n if (Watcher.getSafe(value)?.changed) {\n changed = true;\n // Warning: Do not early break here.\n // We need to process all nested values to be reactive in future changes.\n }\n }\n return changed;\n },\n (changed) => changed && this.#incrementChangedTick()\n );\n }\n }\n\n /**\n * Process `@watch` and `@watch.ref` annotations\n */\n #processWatchAnnotations(target: object) {\n const processor = getAnnotationProcessor(target);\n if (!processor) return;\n\n const watchAnnotations = processor.getPropertyLike(watchKey);\n if (!watchAnnotations) return;\n\n for (const [key, metadata] of watchAnnotations) {\n if (typeof key !== \"string\") continue; // symbol and number keys are not supported\n if (this.#processedKeys.has(key)) continue;\n this.#processedKeys.add(key);\n\n const isShallow = metadata.data.at(-1) === WatchMode.Shallow; // Last annotation prevails\n const getValue = () => (key in target ? (target as any)[key] : metadata.get?.());\n\n reaction(\n () => (isShallow ? shallowReadValue(getValue()) : getValue()),\n () => this.#didChange(KeyPath.build(key))\n );\n }\n }\n\n /**\n * Process `@unwatch` annotations\n */\n #processUnwatchAnnotations(target: object) {\n const processor = getAnnotationProcessor(target);\n if (!processor) return;\n\n const unwatchAnnotations = processor.getPropertyLike(unwatchKey);\n if (!unwatchAnnotations) return;\n\n for (const [key] of unwatchAnnotations) {\n if (typeof key !== \"string\") continue; // symbol and number keys are not supported\n this.#processedKeys.add(key);\n }\n }\n\n /** @internal @ignore */\n [internalToken]() {\n return {\n didChange: this.#didChange.bind(this),\n };\n }\n}\n\n/** @internal @ignore */\nexport function debugWatcher(watcher: Watcher) {\n return watcher[internalToken]();\n}\n","import { action, comparer, computed, IEqualsComparer, makeObservable, observable, reaction, runInAction } from \"mobx\";\nimport { v4 as uuidV4 } from \"uuid\";\nimport { ValidationError, ValidationErrorMapBuilder } from \"./error\";\nimport { StandardNestedFetcher } from \"./nested\";\nimport { KeyPath, ReadonlyKeyPathMultiMap } from \"./keyPath\";\nimport { AsyncJob } from \"./asyncJob\";\n\nconst validatorKey = Symbol(\"validator\");\nconst internalToken = Symbol(\"validator.internal\");\n\n/**\n * Make a target object validatable\n *\n * It's just a shorthand of:\n * ```typescript\n * Validator.get(target).addAsyncHandler(expr, handler, opt)\n * ```\n *\n * @remarks If you're using `make(Auto)Observable`, make sure to call `makeValidatable`\n * after `make(Auto)Observable`.\n *\n * @param target The target object\n * @param expr The expression to observe\n * @param handler The async handler to call when the expression changes\n * @param opt The handler options\n *\n * @returns A function to remove the handler\n */\nexport function makeValidatable<T extends object, Expr>(\n target: T,\n expr: () => Expr,\n handler: Validator.AsyncHandler<T, NoInfer<Expr>>,\n opt?: Validator.HandlerOptions<NoInfer<Expr>>\n): () => void;\n\n/**\n * Make a target object validatable\n *\n * It's just a shorthand of:\n * ```typescript\n * Validator.get(target).addSyncHandler(handler, opt)\n * ```\n *\n * @remarks If you're using `make(Auto)Observable`, make sure to call `makeValidatable`\n * after `make(Auto)Observable`.\n *\n * @param target The target object\n * @param handler The sync handler containing observable expressions\n * @param opt The handler options\n *\n * @returns A function to remove the handler\n */\nexport function makeValidatable<T extends object>(\n target: T,\n handler: Validator.SyncHandler<T>,\n opt?: Validator.HandlerOptions\n): () => void;\n\nexport function makeValidatable(target: object, ...args: any[]) {\n if (typeof args[0] === \"function\" && typeof args[1] === \"function\") {\n const [expr, handler, opt] = args;\n return Validator.get(target).addAsyncHandler(expr, handler, opt);\n }\n const [handler, opt] = args;\n return Validator.get(target).addSyncHandler(handler, opt);\n}\n\n/**\n * Validator for handling synchronous and asynchronous validations\n *\n * - Supports both sync and async validation handlers\n * - Tracks validation state (isValidating)\n * - Provides error access by key path\n * - Supports nested validators\n */\nexport class Validator<T> {\n static defaultDelayMs = 100;\n\n readonly id = uuidV4();\n readonly #errors = observable.map<symbol, ReadonlyKeyPathMultiMap<ValidationError>>([], {\n equals: comparer.structural,\n });\n readonly #nestedFetcher: StandardNestedFetcher<Validator<any>>;\n readonly #reactionTimerIds = observable.map<symbol, number>();\n readonly #reactionResets = new Map<symbol, () => void>();\n readonly #jobs = observable.set<AsyncJob<any>>();\n\n /**\n * Get a validator instance for the target object.\n *\n * @remarks\n * - Returns existing instance if one exists for the target\n * - Creates new instance if none exists\n * - Instances are cached and garbage collected with their targets\n *\n * @throws `TypeError` if the target is not an object.\n */\n static get<T extends object>(target: T): Validator<T> {\n const validator = this.getSafe(target);\n if (!validator) throw new TypeError(\"target: Expected an object\");\n return validator;\n }\n\n /**\n * Get a validator instance for the target object.\n *\n * Same as {@link Validator.get} but returns null instead of throwing an error.\n */\n static getSafe<T>(target: T): Validator<T> | null {\n if (!target || typeof target !== \"object\") {\n return null;\n }\n\n let validator: Validator<T> | null = (target as any)[validatorKey] ?? null;\n if (!validator) {\n validator = new this(internalToken, target);\n Object.defineProperty(target, validatorKey, { value: validator });\n }\n return validator;\n }\n\n private constructor(token: symbol, target: object) {\n if (token !== internalToken) {\n throw new Error(\"private constructor\");\n }\n\n this.#nestedFetcher = new StandardNestedFetcher(target, (entry) => Validator.getSafe(entry.data));\n makeObservable(this);\n }\n\n /** Whether no errors are found */\n @computed\n get isValid() {\n return this.invalidKeyPathCount === 0;\n }\n\n /** The number of invalid keys */\n @computed\n get invalidKeyCount() {\n return this.invalidKeys.size;\n }\n\n /**\n * The keys that have errors\n *\n * Keys of nested objects are NOT included.\n */\n @computed.struct\n get invalidKeys(): ReadonlySet<KeyPath> {\n const seenKeys = new Set<KeyPath>();\n for (const errors of this.#errors.values()) {\n for (const [, error] of errors) {\n seenKeys.add(KeyPath.build(error.key));\n }\n }\n return Object.freeze(seenKeys);\n }\n\n /** The number of invalid key paths */\n @computed\n get invalidKeyPathCount() {\n return this.invalidKeyPaths.size;\n }\n\n /**\n * The key paths that have errors\n *\n * Keys of nested objects are included.\n */\n @computed.struct\n get invalidKeyPaths(): ReadonlySet<KeyPath> {\n const result = new Set<KeyPath>();\n for (const errors of this.#errors.values()) {\n for (const [keyPath] of errors) {\n result.add(keyPath);\n }\n }\n for (const [keyPath, validator] of this.nested) {\n for (const relativeKeyPath of validator.invalidKeyPaths) {\n result.add(KeyPath.build(keyPath, relativeKeyPath));\n }\n }\n return Object.freeze(result);\n }\n\n /** Get the first error message (including nested objects) */\n @computed\n get firstErrorMessage() {\n for (const [, error] of this.findErrors(KeyPath.Self, true)) {\n return error.message;\n }\n return null;\n }\n\n /** Get error messages for the key path */\n getErrorMessages(keyPath: KeyPath, prefixMatch = false) {\n const result = new Set<string>();\n for (const [, error] of this.findErrors(keyPath, prefixMatch)) {\n result.add(error.message);\n }\n return result;\n }\n\n /** Check if the validator has errors for the key path */\n hasErrors(keyPath: KeyPath, prefixMatch = false) {\n for (const _ of this.findErrors(keyPath, prefixMatch)) {\n return true;\n }\n return false;\n }\n\n /**\n * Find errors for the key path\n *\n * - Can do exact or prefix matching\n * - Returns all errors that match the key path\n * - Includes errors from nested validators when using prefix match\n */\n *findErrors(searchKeyPath: KeyPath, prefixMatch = false) {\n yield* this.#findErrors(searchKeyPath, prefixMatch, false);\n }\n\n /** Find errors for the key path */\n *#findErrors(\n searchKeyPath: KeyPath,\n prefixMatch: boolean,\n exact: boolean\n ): Generator<[keyPath: KeyPath, error: ValidationError]> {\n if (KeyPath.isSelf(searchKeyPath)) {\n if (exact) {\n for (const errors of this.#errors.values()) {\n for (const error of errors.findExact(KeyPath.Self)) {\n yield [KeyPath.Self, error];\n }\n }\n } else {\n for (const errors of this.#errors.values()) {\n for (const [keyPath, error] of errors) {\n yield [keyPath, error];\n }\n }\n }\n if (prefixMatch) {\n for (const [keyPath, validator] of this.nested) {\n for (const [relativeKeyPath, error] of validator.#findErrors(KeyPath.Self, true, exact)) {\n yield [KeyPath.build(keyPath, relativeKeyPath), error];\n }\n }\n } else if (!exact) {\n for (const entry of this.#nestedFetcher) {\n const isSelf = entry.key === KeyPath.Self;\n const isDirectChild = entry.keyPath === entry.key; // Ignores entries with subKey (like arrays)\n if (isSelf || isDirectChild) {\n for (const [relativeKeyPath, error] of entry.data.#findErrors(\n KeyPath.Self,\n false,\n !isSelf || !isDirectChild\n )) {\n yield [KeyPath.build(entry.keyPath, relativeKeyPath), error];\n }\n }\n }\n }\n } else {\n for (const errors of this.#errors.values()) {\n const iter = prefixMatch ? errors.findPrefix(searchKeyPath) : errors.findExact(searchKeyPath);\n for (const error of iter) {\n yield [error.keyPath, error];\n }\n }\n ancestorLoop: for (const ancestorKeyPath of KeyPath.getAncestors(searchKeyPath, true)) {\n for (const entry of this.#nestedFetcher.getForKey(ancestorKeyPath)) {\n const childKeyPath =\n prefixMatch && entry.key !== entry.keyPath && KeyPath.getRelative(entry.keyPath, entry.key)\n ? KeyPath.Self\n : KeyPath.getRelative(searchKeyPath, entry.keyPath);\n if (!childKeyPath) continue;\n for (const [relativeKeyPath, error] of entry.data.#findErrors(childKeyPath, prefixMatch, exact)) {\n yield [KeyPath.build(entry.keyPath, relativeKeyPath), error];\n }\n break ancestorLoop;\n }\n }\n }\n }\n\n /**\n * The number of pending/running reactions.\n */\n @computed\n get reactionState() {\n return this.#reactionTimerIds.size;\n }\n\n /**\n * The number of pending/running async jobs.\n */\n @computed\n get asyncState() {\n let count = 0;\n for (const job of this.#jobs) {\n if (job.state !== \"idle\") {\n count++;\n }\n }\n return count;\n }\n\n /** Whether the validator is computing errors */\n @computed\n get isValidating() {\n return this.reactionState > 0 || this.asyncState > 0;\n }\n\n /** Nested validators */\n get nested(): ReadonlyMap<KeyPath, Validator<any>> {\n return this.#nestedFetcher.dataMap;\n }\n\n /**\n * Reset the validator\n *\n * Use with caution.\\\n * Since validation is reactive, errors won't reappear until you make some changes.\n */\n @action\n reset() {\n this.#reactionTimerIds.clear();\n for (const reset of this.#reactionResets.values()) {\n reset();\n }\n\n for (const job of this.#jobs) {\n job.reset();\n }\n\n this.#errors.clear();\n }\n\n /**\n * Update the errors immediately\n *\n * @returns A function to remove the errors\n */\n @action\n updateErrors(key: symbol, handler: Validator.InstantHandler<T>) {\n const builder = new ValidationErrorMapBuilder();\n handler(builder);\n const result = ValidationErrorMapBuilder.build(builder);\n if (result.size > 0) {\n this.#errors.set(key, result);\n } else {\n this.#errors.delete(key);\n }\n return () => {\n this.#errors.delete(key);\n };\n }\n\n /**\n * Add a sync handler\n *\n * @param handler The sync handler containing observable expressions\n *\n * @returns A function to remove the handler\n *\n * @remarks\n * - Handler runs immediately when added for initial validation\n * - Handler is called when observable expressions within it change\n * - Changes are throttled by default delay\n */\n addSyncHandler(handler: Validator.SyncHandler<T>, opt?: Validator.HandlerOptions) {\n const key = Symbol();\n return this.#createReaction({\n key,\n opt,\n expr: () => {\n const builder = new ValidationErrorMapBuilder();\n handler(builder);\n return ValidationErrorMapBuilder.build(builder);\n },\n effect: (result) => {\n if (result.size > 0) {\n this.#errors.set(key, result);\n } else {\n this.#errors.delete(key);\n }\n },\n });\n }\n\n /**\n * Add an async handler\n *\n * @param expr The expression to observe\n * @param handler The async handler to call when the expression changes\n * @param opt The handler options\n *\n * @returns A function to remove the handler\n *\n * @remarks\n * - Handler runs immediately when added for initial validation\n * - Handler is called when the watched expression changes\n * - Changes are throttled by default delay\n * - Provides abort signal for cancellation\n * - Previous validations are aborted when new ones start\n */\n @action\n addAsyncHandler<Expr>(\n expr: () => Expr,\n handler: Validator.AsyncHandler<T, NoInfer<Expr>>,\n opt?: Validator.HandlerOptions<NoInfer<Expr>>\n ) {\n const delayMs = opt?.delayMs ?? Validator.defaultDelayMs;\n\n const key = Symbol();\n const job = new AsyncJob<Expr>({\n handler: async (expr, abortSignal) => {\n const builder = new ValidationErrorMapBuilder();\n try {\n await handler(expr, builder, abortSignal);\n } finally {\n runInAction(() => {\n const result = ValidationErrorMapBuilder.build(builder);\n if (result.size > 0) {\n this.#errors.set(key, result);\n } else {\n this.#errors.delete(key);\n }\n });\n }\n },\n scheduledRunDelayMs: delayMs,\n });\n this.#jobs.add(job);\n\n return this.#createReaction({\n key,\n opt,\n expr,\n effect: (expr) => {\n job.request(expr);\n },\n dispose: () => {\n job.reset();\n this.#jobs.delete(job);\n },\n });\n }\n\n #createReaction<Expr>(args: {\n key: symbol;\n opt?: Validator.HandlerOptions<NoInfer<Expr>>;\n expr: () => Expr;\n effect: (expr: NoInfer<Expr>) => void;\n dispose?: () => void;\n }) {\n const reactionDelayMs = args.opt?.delayMs ?? Validator.defaultDelayMs;\n let initialRun = args.opt?.initialRun ?? true;\n\n const dispose = reaction(\n args.expr,\n (expr) => {\n if (!initialRun && !this.#reactionTimerIds.has(args.key)) return; // In case of reset()\n initialRun = false;\n this.#reactionTimerIds.delete(args.key);\n args.effect(expr);\n },\n {\n fireImmediately: args.opt?.initialRun ?? true,\n scheduler: (fn) => {\n // No need for clearing timer\n const timerId = +setTimeout(fn, reactionDelayMs);\n runInAction(() => {\n this.#reactionTimerIds.set(args.key, timerId!);\n });\n this.#reactionResets.set(args.key, () => {\n clearTimeout(timerId);\n this.#reactionResets.delete(args.key);\n fn();\n });\n },\n }\n );\n\n return (): void => {\n dispose();\n const timerId = this.#reactionTimerIds.get(args.key);\n if (timerId) {\n clearTimeout(timerId);\n }\n runInAction(() => {\n this.#reactionTimerIds.delete(args.key);\n this.#reactionResets.delete(args.key);\n try {\n args.dispose?.();\n } finally {\n this.#errors.delete(args.key);\n }\n });\n };\n }\n}\n\nexport namespace Validator {\n /**\n * Async handler\n *\n * @param expr The expression observed\n * @param