/** * Validators for schema definitions */ import { AnySchemaType, BaseSchemaType, ContainerSchemaType, InferType, LoroListSchema, LoroMapSchema, LoroMovableListSchema, LoroTextSchemaType, LoroTreeSchema, RootSchemaType, SchemaType, } from "./types.js"; import { isObject, getTransform, applyEncode, applyDecode, } from "../core/utils.js"; const schemaValidationCache = new WeakMap>(); function isCacheableValue(value: unknown): value is object { return typeof value === "object" && value !== null; } function isSchemaValidated(schema: SchemaType, value: unknown): boolean { if (!isCacheableValue(value)) return false; const cache = schemaValidationCache.get(value); return cache?.has(schema) ?? false; } function markSchemaValidated(schema: SchemaType, value: unknown): void { if (!isCacheableValue(value)) return; let cache = schemaValidationCache.get(value); if (!cache) { cache = new WeakSet(); schemaValidationCache.set(value, cache); } cache.add(schema); } /** * Type guard for LoroMapSchema */ export function isLoroMapSchema>( schema?: SchemaType, ): schema is LoroMapSchema { return !!schema && (schema as BaseSchemaType).type === "loro-map"; } /** * Type guard for LoroListSchema */ export function isLoroListSchema( schema?: SchemaType, ): schema is LoroListSchema { return !!schema && (schema as BaseSchemaType).type === "loro-list"; } export function isListLikeSchema( schema?: SchemaType, ): schema is LoroListSchema | LoroMovableListSchema { return isLoroListSchema(schema) || isLoroMovableListSchema(schema); } export function isLoroMovableListSchema( schema?: SchemaType, ): schema is LoroMovableListSchema { return !!schema && (schema as BaseSchemaType).type === "loro-movable-list"; } /** * Type guard for RootSchemaType */ export function isRootSchemaType>( schema?: SchemaType, ): schema is RootSchemaType { return !!schema && (schema as BaseSchemaType).type === "schema"; } /** * Type guard for LoroTextSchemaType */ export function isLoroTextSchema( schema?: SchemaType, ): schema is LoroTextSchemaType { return !!schema && (schema as BaseSchemaType).type === "loro-text"; } /** * Type guard for LoroTreeSchema */ export function isLoroTreeSchema>( schema?: SchemaType, ): schema is LoroTreeSchema { return !!schema && (schema as BaseSchemaType).type === "loro-tree"; } /** * Type guard for AnySchemaType */ export function isAnySchema(schema?: SchemaType): schema is AnySchemaType { return !!schema && (schema as BaseSchemaType).type === "any"; } /** * Check if a schema is for a Loro container */ export function isContainerSchema( schema?: SchemaType, ): schema is ContainerSchemaType { return ( !!schema && (schema.type === "loro-map" || schema.type === "loro-list" || schema.type === "loro-text" || schema.type === "loro-movable-list" || schema.type === "loro-tree") ); } /** * Validate a value against a schema */ export function validateSchema( schema: S, value: unknown, ): { valid: boolean; errors?: string[] } { const errors: string[] = []; const actualType = (schema as BaseSchemaType).type; // Check if value is required if (schema.options.required && (value === undefined || value === null)) { errors.push("Value is required"); return { valid: false, errors }; } // If value is undefined or null and not required, it's valid if (value === undefined || value === null) { return { valid: true }; } if (isSchemaValidated(schema, value)) { return { valid: true }; } // Validate based on schema type switch (actualType) { case "any": { // Accept JSON-like values (primitives, arrays, and plain objects) if ( typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean" && value !== null && value !== undefined && !Array.isArray(value) && !isObject(value) ) { errors.push("Value must be JSON-like"); } break; } case "string": case "number": case "boolean": validateTransformablePrimitive( schema, value, actualType, errors, ); break; case "ignore": // Ignored fields are always valid break; case "loro-text": if (typeof value !== "string") { errors.push("Content must be a string"); } break; case "loro-map": if (!isObject(value)) { errors.push("Value must be an object"); } else { if (isLoroMapSchema(schema)) { // Validate each property in the map for (const key in schema.definition) { if ( Object.prototype.hasOwnProperty.call( schema.definition, key, ) ) { const propSchema = schema.definition[key]; const propValue = value[key]; const result = validateSchema( propSchema, propValue, ); if (!result.valid && result.errors) { // Prepend property name to each error const prefixedErrors = result.errors.map( (err) => `${key}: ${err}`, ); errors.push(...prefixedErrors); } } } } } break; case "loro-movable-list": case "loro-list": if (!Array.isArray(value)) { errors.push("Value must be an array"); } else if ( isLoroListSchema(schema) || isLoroMovableListSchema(schema) ) { const itemSchema = schema.itemSchema; value.forEach((item, index) => { const result = validateSchema(itemSchema, item); if (!result.valid && result.errors) { // Prepend array index to each error const prefixedErrors = result.errors.map( (err) => `Item ${index}: ${err}`, ); errors.push(...prefixedErrors); } }); } break; case "loro-tree": { if (!Array.isArray(value)) { errors.push("Value must be an array of tree nodes"); break; } if (!isLoroTreeSchema(schema)) { errors.push("Invalid tree schema"); break; } // Validate nodes recursively const validateNode = (node: unknown, path: string) => { if (!isObject(node)) { errors.push(`${path}: Node must be an object`); return; } const n = node; if (n.id != null && typeof n.id !== "string") { errors.push(`${path}: id must be a string if provided`); } //TODO: validate valid TreeID // Validate data against nodeSchema const dataResult = validateSchema(schema.nodeSchema, n.data); if (!dataResult.valid && dataResult.errors) { errors.push( ...dataResult.errors.map((e) => `${path}.data: ${e}`), ); } // Children if (!Array.isArray(n.children)) { errors.push(`${path}: children must be an array`); } else { n.children.forEach((child, idx) => { validateNode(child, `${path}.children[${idx}]`); }); } }; value.forEach((node, i) => { validateNode(node, `node[${i}]`); }); break; } case "schema": if (!isObject(value)) { errors.push("Value must be an object"); } else if (isRootSchemaType(schema)) { if (!isObject(value)) { errors.push("Value must be an object"); } else { // Validate each property in the schema for (const key in schema.definition) { if ( Object.prototype.hasOwnProperty.call( schema.definition, key, ) ) { const propSchema = schema.definition[key]; const propValue = value[key]; const result = validateSchema( propSchema, propValue, ); if (!result.valid && result.errors) { // Prepend property name to each error const prefixedErrors = result.errors.map( (err) => `${key}: ${err}`, ); errors.push(...prefixedErrors); } } } for (const key in value) { if ( !Object.prototype.hasOwnProperty.call( schema.definition, key, ) ) { errors.push(`Unknown property: ${key}`); } } } } else { errors.push(`Should be a schema, but got ${actualType}`); } break; default: errors.push( `Unknown schema type: ${actualType}` ); } // Run custom validation if provided if ( schema.options.validate && typeof schema.options.validate === "function" ) { try { const customValidation = schema.options.validate(value); if (customValidation !== true) { const errorMessage = typeof customValidation === "string" ? customValidation : "Value failed custom validation"; errors.push(errorMessage); } } catch (error) { errors.push(`Validation error: ${String(error)}`); } } if (errors.length === 0) { markSchemaValidated(schema, value); return { valid: true }; } return { valid: false, errors }; } function validateTransformablePrimitive( schema: S, value: {}, expectedType: "string" | "number" | "boolean", errors: string[], ) { const transform = getTransform(schema); if (transform) { // Call transform's validate if present if (transform.validate) { try { // Value is known to be non-null/undefined at this point const result = transform.validate(value); if (result !== true) { errors.push( typeof result === "string" ? result : "Transform validation failed", ); } } catch (error) { errors.push(`Transform validation error: ${String(error)}`); } } // Optionally check encoded type if (transform.validateEncodedType) { try { const encoded = applyEncode(schema, value); if (typeof encoded !== expectedType) { errors.push( `Transform encode must return a ${expectedType}, got ${typeof encoded}`, ); } } catch (error) { errors.push( `Transform encode validation error: ${String(error)}`, ); } } } else { // No transform - check value is the expected primitive type if (typeof value !== expectedType) { errors.push(`Value must be a ${expectedType}`); } } } /** * Get default value for a schema * Based on the schema type, it might return a plain value or a wrapped value */ export function getDefaultValue( schema: S, ): InferType | undefined { // If a default value is provided in options, use it if ("defaultValue" in schema.options) { const defaultValue = schema.options.defaultValue; return defaultValue as InferType; } // Otherwise, create a default based on the schema type const schemaType = (schema as BaseSchemaType).type; switch (schemaType) { case "any": { // Only honor explicit defaultValue (handled above); otherwise undefined return undefined; } case "string": { if (schema.options.required === false) { return undefined; } if (getTransform(schema)) { return undefined; } return "" as InferType; } case "number": { if (schema.options.required === false) { return undefined; } if (getTransform(schema)) { return undefined; } return 0 as InferType; } case "boolean": { if (schema.options.required === false) { return undefined; } if (getTransform(schema)) { return undefined; } return false as InferType; } case "loro-text": { const value = schema.options.required ? "" : undefined; if (value === undefined) return undefined; return value as InferType; } case "loro-map": { if (isLoroMapSchema(schema)) { const result: Record = {}; for (const key in schema.definition) { if ( Object.prototype.hasOwnProperty.call( schema.definition, key, ) ) { const value = getDefaultValue(schema.definition[key]); if (value !== undefined) { result[key] = value; } } } return result as InferType; } return {} as InferType; } case "loro-list": return [] as InferType; case "loro-tree": { const value = schema.options.required ? [] : undefined; if (value === undefined) return undefined; return value as InferType; } case "schema": { if (isRootSchemaType(schema)) { const result: Record = {}; for (const key in schema.definition) { if ( Object.prototype.hasOwnProperty.call( schema.definition, key, ) ) { const value = getDefaultValue(schema.definition[key]); if (value !== undefined) { result[key] = value; } } } return result as InferType; } return {} as InferType; } default: return undefined; } } /** * Creates a properly typed value based on the schema * This ensures consistency between schema types and runtime values */ export function createValueFromSchema( schema: S, value: unknown, ): InferType { // For primitive types, handle wrapping consistently const schemaType = (schema as BaseSchemaType).type; if ( schemaType === "string" || schemaType === "number" || schemaType === "boolean" ) { return applyDecode(schema, value) as InferType; } // For complex types, pass through as is return value as InferType; }