loro-mirror
Version:
Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.
312 lines • 11.4 kB
JavaScript
import { isObject } from "../core/utils";
/**
* Type guard for LoroMapSchema
*/
export function isLoroMapSchema(schema) {
return schema.type === "loro-map";
}
/**
* Type guard for LoroListSchema
*/
export function isLoroListSchema(schema) {
return schema.type === "loro-list";
}
export function isListLikeSchema(schema) {
return isLoroListSchema(schema) || isLoroMovableListSchema(schema);
}
export function isLoroMovableListSchema(schema) {
return schema.type === "loro-movable-list";
}
/**
* Type guard for RootSchemaType
*/
export function isRootSchemaType(schema) {
return schema.type === "schema";
}
/**
* Type guard for LoroTextSchemaType
*/
export function isLoroTextSchema(schema) {
return schema.type === "loro-text";
}
/**
* Type guard for LoroTreeSchema
*/
export function isLoroTreeSchema(schema) {
return schema.type === "loro-tree";
}
/**
* Check if a schema is for a Loro container
*/
export function isContainerSchema(schema) {
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, value) {
const errors = [];
// 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 };
}
// Validate based on schema type
switch (schema.type) {
case "string":
if (typeof value !== "string") {
errors.push("Value must be a string");
}
break;
case "number":
if (typeof value !== "number") {
errors.push("Value must be a number");
}
break;
case "boolean":
if (typeof value !== "boolean") {
errors.push("Value must be a boolean");
}
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)) {
// Validate each item in the list
value.forEach((item, index) => {
const result = validateSchema(schema.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, path) => {
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 ${schema.type}`);
}
break;
default:
errors.push(`Unknown schema type: ${schema.type}`);
}
// 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)}`);
}
}
return errors.length > 0 ? { valid: false, errors } : { valid: true };
}
/**
* 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) {
// If a default value is provided in options, use it
if ("defaultValue" in schema.options) {
const defaultValue = schema.options.defaultValue;
return defaultValue;
}
// Otherwise, create a default based on the schema type
const schemaType = schema.type;
switch (schemaType) {
case "string": {
const value = schema.options.required ? "" : undefined;
if (value === undefined)
return undefined;
return value;
}
case "number": {
const value = schema.options.required ? 0 : undefined;
if (value === undefined)
return undefined;
return value;
}
case "boolean": {
const value = schema.options.required ? false : undefined;
if (value === undefined)
return undefined;
return value;
}
case "loro-text": {
const value = schema.options.required ? "" : undefined;
if (value === undefined)
return undefined;
return value;
}
case "loro-map": {
if (isLoroMapSchema(schema)) {
const result = {};
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;
}
return {};
}
case "loro-list":
return [];
case "loro-tree": {
const value = schema.options.required ? [] : undefined;
if (value === undefined)
return undefined;
return value;
}
case "schema": {
if (isRootSchemaType(schema)) {
const result = {};
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;
}
return {};
}
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, value) {
// For primitive types, handle wrapping consistently
const schemaType = schema.type;
if (schemaType === "string" || schemaType === "number" ||
schemaType === "boolean") {
return value;
}
// For complex types, pass through as is
return value;
}
//# sourceMappingURL=validators.js.map