UNPKG

loro-mirror

Version:

Type-safe state management synchronized with Loro CRDT via a declarative schema and bidirectional mirroring.

379 lines (332 loc) 9.94 kB
/** * Utility functions for Loro Mirror core */ import { Container, ContainerID, ContainerType, LoroDoc } from "loro-crdt"; import { SchemaType } from "../schema"; import { Change, InferContainerOptions } from "./mirror"; /** * Check if a value is an object */ export function isObject(value: unknown): value is Record<string, unknown> { return ( typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof RegExp) && typeof value !== "function" ); } /** * Performs a deep equality check between two values */ export function deepEqual(a: unknown, b: unknown): boolean { // Check if both values are the same reference or primitive equality if (a === b) return true; // If either value is null or not an object or function, they can't be deeply equal unless they were strictly equal (checked above) if ( a === null || b === null || (typeof a !== "object" && typeof a !== "function") || (typeof b !== "object" && typeof b !== "function") ) { return false; } // Handle arrays if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!deepEqual(a[i], b[i])) return false; } return true; } // Handle Date objects if (a instanceof Date && b instanceof Date) { return a.getTime() === b.getTime(); } // Handle RegExp objects if (a instanceof RegExp && b instanceof RegExp) { return a.toString() === b.toString(); } // Handle other objects if (!Array.isArray(a) && !Array.isArray(b)) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if ( !deepEqual( (a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key], ) ) return false; } return true; } return false; } /** * Get a value from a nested object using a path array */ export function getPathValue( obj: Record<string, unknown>, path: string[], ): unknown { let current: unknown = obj; for (let i = 0; i < path.length; i++) { if (current === undefined || current === null) return undefined; const key = path[i]; if (typeof current !== "object") return undefined; current = (current as Record<string, unknown>)[key]; } return current; } /** * Set a value in a nested object using a path array * Note: This modifies the object directly (intended for use with Immer) */ export function setPathValue( obj: Record<string, unknown>, path: string[], value: unknown, ): void { if (path.length === 0) return; let current: Record<string, unknown> = obj; const lastIndex = path.length - 1; for (let i = 0; i < lastIndex; i++) { const key = path[i]; // Create nested objects if they don't exist if ( current[key] === undefined || current[key] === null || typeof current[key] !== "object" ) { current[key] = {}; } current = current[key] as Record<string, unknown>; } // Set the value at the final path const lastKey = path[lastIndex]; if (value === undefined) { delete current[lastKey]; } else { current[lastKey] = value; } } type ContainerValue = { cid: string; value: unknown; }; export function valueIsContainer(value: unknown): value is ContainerValue { return ( value != null && typeof value === "object" && "cid" in value && "value" in value ); } export function valueIsContainerOfType( value: unknown, containerType: string, ): value is ContainerValue { return valueIsContainer(value) && value.cid.endsWith(containerType); } export function containerIdToContainerType( containerId: ContainerID, ): ContainerType | undefined { return containerId.split(":")[2] as ContainerType; } export function getRootContainerByType( doc: LoroDoc, key: string, type: ContainerType, ): Container { if (type === "Text") { return doc.getText(key); } else if (type === "List") { return doc.getList(key); } else if (type === "MovableList") { return doc.getMovableList(key); } else if (type === "Map") { return doc.getMap(key); } else if (type === "Tree") { return doc.getTree(key); } else { throw new Error(); } } /* Insert a child change to a map */ export function insertChildToMap( containerId: ContainerID | "", key: string, value: unknown, ): Change { if (isObject(value)) { return { container: containerId, key, value: value, kind: "insert-container", childContainerType: "Map", }; } else if (Array.isArray(value)) { return { container: containerId, key, value: value, kind: "insert-container", childContainerType: "List", }; } else { return { container: containerId, key, value: value, kind: "insert", }; } } /* Try to update a change to insert a container */ export function tryUpdateToContainer( change: Change, toUpdate: boolean, schema: SchemaType | undefined, ): Change { if (!toUpdate) { return change; } if (change.kind !== "insert" && change.kind !== "set") { return change; } const containerType = schema ? (schemaToContainerType(schema) ?? tryInferContainerType(change.value)) : undefined; if (containerType == null) { return change; } if (change.kind === "insert") { return { container: change.container, key: change.key, value: change.value, kind: "insert-container", childContainerType: containerType, }; } if (change.kind === "set") { return { container: change.container, key: change.key, value: change.value, kind: "set-container", childContainerType: containerType, }; } return change; } /* Get container type from schema */ export function schemaToContainerType( schema: SchemaType, ): ContainerType | undefined { const containerType = schema.getContainerType(); return containerType === null ? undefined : containerType; } /* Try to infer container type from value */ export function tryInferContainerType( value: unknown, defaults?: InferContainerOptions, ): ContainerType | undefined { if (isObject(value)) { return "Map"; } else if (Array.isArray(value)) { if (defaults?.defaultMovableList) { return "MovableList"; } return "List"; } else if (typeof value === "string") { if (defaults?.defaultLoroText) { return "Text"; } else { return; } } } /* Check if value is of a given container type */ export function isValueOfContainerType( containerType: ContainerType, value: unknown, ): boolean { switch (containerType) { case "MovableList": case "List": return typeof value === "object" && Array.isArray(value); case "Map": return typeof value === "object" && value !== null; case "Text": return typeof value === "string" && value !== null; case "Tree": return typeof value === "object" && Array.isArray(value); default: return false; } } /* Infer container type from value */ export function inferContainerTypeFromValue( value: unknown, defaults?: InferContainerOptions, ): "loro-map" | "loro-list" | "loro-text" | "loro-movable-list" | undefined { if (isObject(value)) { return "loro-map"; } else if (Array.isArray(value)) { if (defaults?.defaultMovableList) { return "loro-movable-list"; } return "loro-list"; } else if (typeof value === "string") { if (defaults?.defaultLoroText) { return "loro-text"; } } else { return; } } export type ObjectLike = Record<string, unknown>; export type ArrayLike = Array<unknown>; /* Check if value is an object */ export function isObjectLike(value: unknown): value is ObjectLike { return typeof value === "object"; } /* Check if value is an array */ export function isArrayLike(value: unknown): value is ArrayLike { return Array.isArray(value); } /* Check if value is a string */ export function isStringLike(value: unknown): value is string { return typeof value === "string"; } /* Type guard to ensure state and schema are of the correct type */ export function isStateAndSchemaOfType< S extends ObjectLike | ArrayLike | string, T extends SchemaType, >( values: { oldState: unknown; newState: unknown; schema: SchemaType | undefined; }, stateGuard: (value: unknown) => value is S, schemaGuard: (schema: SchemaType) => schema is T, ): values is { oldState: S; newState: S; schema: T | undefined } { return ( stateGuard(values.oldState) && stateGuard(values.newState) && (!values.schema || schemaGuard(values.schema)) ); } export function isTreeID(id: unknown): boolean { if (!(typeof id === "string")) return false; const r = /[0-9]+@[0-9]+/; return !!id.match(r); }