diff --git a/src/json-crdt/history/UndoRedoStack.ts b/src/json-crdt/history/UndoRedoStack.ts new file mode 100644 index 0000000000..526e8f1234 --- /dev/null +++ b/src/json-crdt/history/UndoRedoStack.ts @@ -0,0 +1,41 @@ +export interface UndoItem { + undo(): RedoItem; +} + +export interface RedoItem { + redo(): UndoItem; +} + +export class UndoRedoStack { + private undoStack: UndoItem[] = []; + private redoStack: RedoItem[] = []; + + public undoLength(): number { + return this.undoStack.length; + } + + public redoLength(): number { + return this.redoStack.length; + } + + public push(undo: UndoItem): RedoItem[] { + const redoStack = this.redoStack; + this.redoStack = []; + this.undoStack.push(undo); + return redoStack; + } + + public undo(): void { + const undo = this.undoStack.pop(); + if (!undo) return; + const redo = undo.undo(); + this.redoStack.push(redo); + } + + public redo(): void { + const redo = this.redoStack.pop(); + if (!redo) return; + const undo = redo.redo(); + this.undoStack.push(undo); + } +} diff --git a/src/json-crdt/model/Model.ts b/src/json-crdt/model/Model.ts index 0d772ee943..6dbcd1872d 100644 --- a/src/json-crdt/model/Model.ts +++ b/src/json-crdt/model/Model.ts @@ -6,7 +6,8 @@ import {JsonCrdtPatchOperation, Patch} from '../../json-crdt-patch/Patch'; import {ModelApi} from './api/ModelApi'; import {ORIGIN, SESSION, SYSTEM_SESSION_TIME} from '../../json-crdt-patch/constants'; import {randomSessionId} from './util'; -import {RootNode, ValNode, VecNode, ObjNode, StrNode, BinNode, ArrNode, BuilderNodeToJsonNode} from '../nodes'; +import {RootNode, ValNode, VecNode, ObjNode, StrNode, BinNode, ArrNode} from '../nodes'; +import {SchemaToJsonNode} from '../schema/types'; import {printTree} from '../../util/print/printTree'; import {Extensions} from '../extensions/Extensions'; import {AvlMap} from '../../util/trees/avl/AvlMap'; @@ -378,7 +379,7 @@ export class Model> implements Printable { * @param schema The schema to set for this model. * @returns Strictly typed model. */ - public setSchema(schema: S): Model> { + public setSchema(schema: S): Model> { if (this.clock.time < 2) this.api.root(schema); return this; } diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index c84170098f..c636536fe2 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -192,6 +192,8 @@ export class ModelApi implements SyncStore extends Identifiable { } export type JsonNodeView = N extends JsonNode ? V : {[K in keyof N]: JsonNodeView}; - -// prettier-ignore -export type BuilderNodeToJsonNode = S extends builder.str - ? nodes.StrNode - : S extends builder.bin - ? nodes.BinNode - : S extends builder.con - ? nodes.ConNode - : S extends builder.val - ? nodes.ValNode> - : S extends builder.vec - ? nodes.VecNode<{[K in keyof T]: BuilderNodeToJsonNode}> - : S extends builder.obj - ? nodes.ObjNode<{[K in keyof T]: BuilderNodeToJsonNode}> - : S extends builder.arr - ? nodes.ArrNode> - : JsonNode; diff --git a/src/json-crdt/schema/__tests__/toSchema.spec.ts b/src/json-crdt/schema/__tests__/toSchema.spec.ts new file mode 100644 index 0000000000..4850337d4f --- /dev/null +++ b/src/json-crdt/schema/__tests__/toSchema.spec.ts @@ -0,0 +1,89 @@ +import {NodeBuilder, s, nodes} from '../../../json-crdt-patch'; +import {deepEqual} from '../../../json-equal/deepEqual'; +import {cmpUint8Array} from '../../../util/buffers/cmpUint8Array'; +import {Model} from '../../model'; +import {toSchema} from '../toSchema'; + +const cmp = (a: NodeBuilder, b: NodeBuilder): boolean => { + if (a instanceof nodes.con && b instanceof nodes.con) return deepEqual(a.raw, b.raw); + else if (a instanceof nodes.val && b instanceof nodes.val) return cmp(a.value, b.value); + else if (a instanceof nodes.obj && b instanceof nodes.obj) { + const objAKeys = Object.keys(a.obj); + const objBKeys = Object.keys(a.obj); + const objALen = objAKeys.length; + const objBLen = objBKeys.length; + if (objALen !== objBLen) return false; + const optAKeys = Object.keys(a.opt || {}); + const optBKeys = Object.keys(b.opt || {}); + const optALen = optAKeys.length; + const optBLen = optBKeys.length; + if (optALen !== optBLen) return false; + for (let i = 0; i < objALen; i++) { + const key = objAKeys[i]; + if (!cmp(a.obj[key], b.obj[key])) return false; + } + for (let i = 0; i < optALen; i++) { + const key = optAKeys[i]; + if (!cmp(a.opt![key], b.opt![key])) return false; + } + return true; + } else if (a instanceof nodes.vec && b instanceof nodes.vec) { + const vecA = a.value; + const vecB = b.value; + const len = vecA.length; + if (len !== vecB.length) return false; + for (let i = 0; i < len; i++) if (!cmp(vecA[i], vecA[i])) return false; + return true; + } else if (a instanceof nodes.str && b instanceof nodes.str) return a.raw === b.raw; + else if (a instanceof nodes.bin && b instanceof nodes.bin) return cmpUint8Array(a.raw, b.raw); + else if (a instanceof nodes.arr && b instanceof nodes.arr) { + const arrA = a.arr; + const arrB = b.arr; + const len = arrA.length; + if (len !== arrB.length) return false; + for (let i = 0; i < len; i++) if (!cmp(arrA[i], arrB[i])) return false; + return true; + } + return false; +}; + +test('can infer schema of a document nodes', () => { + const con = s.con('con'); + const str = s.str('hello'); + const obj = s.obj({ + id: s.con('id'), + val: s.val(s.str('world')), + }); + const schema = s.obj({ + con, + str, + bin: s.bin(new Uint8Array([1, 2, 3])), + obj, + vec: s.vec(s.con(1), s.con({foo: 'bar'})), + arr: s.arr([s.con(1), s.con({foo: 'bar'})]), + }); + const model = Model.withLogicalClock().setSchema(schema); + const node = model.root.node(); + const schema2 = toSchema(node); + expect(cmp(schema, schema2)).toBe(true); + const conSchema = toSchema(model.api.const('con').node); + expect(cmp(con, conSchema)).toBe(true); + expect(cmp(str, conSchema)).toBe(false); + const strSchema = toSchema(model.api.str('str').node); + expect(cmp(str, strSchema)).toBe(true); + expect(cmp(con, strSchema)).toBe(false); + const objSchema = toSchema(model.api.obj('obj').node); + expect(cmp(obj, objSchema)).toBe(true); + expect(cmp(con, objSchema)).toBe(false); +}); + +test('can infer schema of a typed model', () => { + const schema = s.obj({ + id: s.con('id'), + val: s.val(s.str('world')), + }); + const model = Model.withLogicalClock().setSchema(schema); + const schema2 = toSchema(model.root.node()); + expect(schema2.obj.id).toBeInstanceOf(nodes.con); + expect(schema2.obj.val).toBeInstanceOf(nodes.val); +}); diff --git a/src/json-crdt/schema/__tests__/types.spec.ts b/src/json-crdt/schema/__tests__/types.spec.ts new file mode 100644 index 0000000000..a4eefe5eed --- /dev/null +++ b/src/json-crdt/schema/__tests__/types.spec.ts @@ -0,0 +1,57 @@ +import {s} from '../../../json-crdt-patch'; +import {Model} from '../../model'; +import {JsonNodeToSchema, SchemaToJsonNode} from '../types'; + +describe('can infer schema of JSON CRDT nodes', () => { + test('con', () => { + const schema1 = s.con(123); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('val', () => { + const schema1 = s.val(s.con(true)); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('obj', () => { + const schema1 = s.obj({ + hello: s.con('world'), + }); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('vec', () => { + const schema1 = s.vec(s.con(1), s.val(s.con(2))); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('str', () => { + const schema1 = s.str('asdf'); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('bin', () => { + const schema1 = s.bin(new Uint8Array([1, 2, 3])); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('arr', () => { + const schema1 = s.arr([s.con(1), s.val(s.con(2))]); + const schema2: JsonNodeToSchema> = schema1; + }); + + test('from typed model', () => { + const model = Model.withLogicalClock().setSchema( + s.obj({ + id: s.con('asdf'), + age: s.val(s.con(42)), + }), + ); + type Node = ReturnType; + type Schema = JsonNodeToSchema; + const schema: Schema = s.obj({ + id: s.con('asdf'), + age: s.val(s.con(42)), + }); + }); +}); diff --git a/src/json-crdt/schema/toSchema.ts b/src/json-crdt/schema/toSchema.ts new file mode 100644 index 0000000000..c21cb1faf5 --- /dev/null +++ b/src/json-crdt/schema/toSchema.ts @@ -0,0 +1,36 @@ +import {JsonNode, ConNode, ValNode, ObjNode, VecNode, StrNode, BinNode, ArrNode} from '../nodes'; +import {NodeBuilder, s} from '../../json-crdt-patch'; +import type {JsonNodeToSchema} from './types'; + +/** + * Converts any JSON CRDT node to a schema representation. The schema can be + * used to copy the structure of the JSON CRDT node to another document or + * another location in the same document. + * + * @param node JSON CRDT node to recursively convert to schema. + * @returns Schema representation of the JSON CRDT node. + */ +export const toSchema = >(node: N): JsonNodeToSchema => { + if (node instanceof ConNode) return s.con(node.val) as any; + if (node instanceof ValNode) return s.val(toSchema(node.node())) as any; + if (node instanceof ObjNode) { + const obj: Record = {}; + node.nodes((child, key) => (obj[key] = toSchema(child))); + return s.obj(obj) as any; + } + if (node instanceof VecNode) { + const arr: NodeBuilder[] = []; + node.children((child) => arr.push(toSchema(child))); + return s.vec(...arr) as any; + } + if (node instanceof StrNode) return s.str(node.view()) as any; + if (node instanceof BinNode) return s.bin(node.view()) as any; + if (node instanceof ArrNode) { + const arr: NodeBuilder[] = []; + node.children((child) => { + if (child) arr.push(toSchema(child)); + }); + return s.arr(arr) as any; + } + return s.con(undefined) as any; +}; diff --git a/src/json-crdt/schema/types.ts b/src/json-crdt/schema/types.ts new file mode 100644 index 0000000000..01928ae3c9 --- /dev/null +++ b/src/json-crdt/schema/types.ts @@ -0,0 +1,36 @@ +import type {nodes as builder} from '../../json-crdt-patch'; +import type * as nodes from '../nodes'; + +// prettier-ignore +export type SchemaToJsonNode = S extends builder.str + ? nodes.StrNode + : S extends builder.bin + ? nodes.BinNode + : S extends builder.con + ? nodes.ConNode + : S extends builder.val + ? nodes.ValNode> + : S extends builder.vec + ? nodes.VecNode<{[K in keyof T]: SchemaToJsonNode}> + : S extends builder.obj + ? nodes.ObjNode<{[K in keyof T]: SchemaToJsonNode}> + : S extends builder.arr + ? nodes.ArrNode> + : nodes.JsonNode; + +// prettier-ignore +export type JsonNodeToSchema = N extends nodes.StrNode + ? builder.str + : N extends nodes.BinNode + ? builder.bin + : N extends nodes.ConNode + ? builder.con + : N extends nodes.ValNode + ? builder.val> + : N extends nodes.VecNode + ? builder.vec<{[K in keyof T]: JsonNodeToSchema}> + : N extends nodes.ObjNode + ? builder.obj<{[K in keyof T]: JsonNodeToSchema}> + : N extends nodes.ArrNode + ? builder.arr> + : builder.con;