From 4d6316ee50e0df32e07cf58f324a9416493a8b29 Mon Sep 17 00:00:00 2001 From: minjungw00 Date: Wed, 4 Dec 2024 17:23:18 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20crdt=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=B3=B5=EA=B5=AC=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #215 --- @noctaCrdt/Crdt.ts | 369 ++++++++++++++++++++++----------------- @noctaCrdt/LinkedList.ts | 216 ++++++++++++----------- 2 files changed, 322 insertions(+), 263 deletions(-) diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index 5b0c125d..8d01d7c0 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -15,10 +15,19 @@ import { BackgroundColorType, } from "./Interfaces"; +// 트랜잭션 관리를 위한 인터페이스 +interface OperationState> { + previousState: { + clock: number; + linkedList: LinkedList; + }; +} + export class CRDT> { clock: number; client: number; LinkedList: LinkedList; + private operationState: OperationState | null = null; constructor(client: number, LinkedListClass: new () => LinkedList) { this.clock = 0; @@ -26,6 +35,43 @@ export class CRDT> { this.LinkedList = new LinkedListClass(); } + // 트랜잭션 시작 + private beginOperation(): void { + this.operationState = { + previousState: { + clock: this.clock, + linkedList: new (this.LinkedList.constructor as any)(this.LinkedList), + }, + }; + } + + // 트랜잭션 롤백 + private rollback(): void { + if (this.operationState) { + this.clock = this.operationState.previousState.clock; + this.LinkedList = this.operationState.previousState.linkedList; + this.operationState = null; + } + } + + // 트랜잭션 커밋 + private commit(): void { + this.operationState = null; + } + + // 연산 실행 래퍼 함수 + protected executeOperation(operation: () => R): R { + this.beginOperation(); + try { + const result = operation(); + this.commit(); + return result; + } catch (error) { + this.rollback(); + throw error; + } + } + localInsert(index: number, value: string, blockId?: BlockId, pageId?: string): any { // 기본 CRDT에서는 구현하지 않고, 하위 클래스에서 구현 throw new Error("Method not implemented."); @@ -63,9 +109,11 @@ export class CRDT> { } deserialize(data: any): void { - this.clock = data.clock; - this.client = data.client; - this.LinkedList.deserialize(data.LinkedList); + this.executeOperation(() => { + this.clock = data.clock; + this.client = data.client; + this.LinkedList.deserialize(data.LinkedList); + }); } } @@ -79,33 +127,29 @@ export class EditorCRDT extends CRDT { } localInsert(index: number, value: string): RemoteBlockInsertOperation { - const id = new BlockId(this.clock + 1, this.client); - const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); - this.clock += 1; - return { node: remoteInsertion.node } as RemoteBlockInsertOperation; + return this.executeOperation(() => { + const id = new BlockId(this.clock + 1, this.client); + const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; + return { node: remoteInsertion.node } as RemoteBlockInsertOperation; + }); } localDelete(index: number, blockId: undefined, pageId: string): RemoteBlockDeleteOperation { - if (index < 0 || index >= this.LinkedList.spread().length) { - throw new Error(`Invalid index: ${index}`); - } - - const nodeToDelete = this.LinkedList.findByIndex(index); - if (!nodeToDelete) { - throw new Error(`Node not found at index: ${index}`); - } - - const operation: RemoteBlockDeleteOperation = { - type: "blockDelete", - targetId: nodeToDelete.id, - clock: this.clock, - pageId, - }; - - this.LinkedList.deleteNode(nodeToDelete.id); - this.clock += 1; - - return operation; + return this.executeOperation(() => { + const nodeToDelete = this.LinkedList.findByIndex(index); + const operation: RemoteBlockDeleteOperation = { + type: "blockDelete", + targetId: nodeToDelete.id, + clock: this.clock, + pageId, + }; + + this.LinkedList.deleteNode(nodeToDelete.id); + this.clock += 1; + + return operation; + }); } localUpdate(block: Block, pageId: string): RemoteBlockUpdateOperation { @@ -119,37 +163,41 @@ export class EditorCRDT extends CRDT { return { type: "blockUpdate", node: updatedBlock, pageId }; } - remoteUpdate(block: Block, pageId: string): RemoteBlockUpdateOperation { - const updatedBlock = this.LinkedList.nodeMap[JSON.stringify(block.id)]; - updatedBlock.animation = block.animation; - updatedBlock.icon = block.icon; - updatedBlock.indent = block.indent; - updatedBlock.style = block.style; - updatedBlock.type = block.type; - updatedBlock.listIndex = block.listIndex || undefined; - return { type: "blockUpdate", node: updatedBlock, pageId }; - } - remoteInsert(operation: RemoteBlockInsertOperation): void { - const newNodeId = new BlockId(operation.node.id.clock, operation.node.id.client); - const newNode = new Block(operation.node.value, newNodeId); + this.executeOperation(() => { + const newNodeId = new BlockId(operation.node.id.clock, operation.node.id.client); + const newNode = new Block(operation.node.value, newNodeId); - newNode.next = operation.node.next; - newNode.prev = operation.node.prev; - newNode.indent = operation.node.indent; - newNode.listIndex = operation.node.listIndex || undefined; - this.LinkedList.insertById(newNode); + newNode.next = operation.node.next; + newNode.prev = operation.node.prev; + newNode.indent = operation.node.indent; + newNode.listIndex = operation.node.listIndex || undefined; - this.clock = Math.max(this.clock, operation.node.id.clock) + 1; + this.LinkedList.insertById(newNode); + this.clock = Math.max(this.clock, operation.node.id.clock) + 1; + }); } remoteDelete(operation: RemoteBlockDeleteOperation): void { - const { targetId, clock } = operation; - if (targetId) { + this.executeOperation(() => { + const { clock } = operation; const targetNodeId = new BlockId(operation.targetId.clock, operation.targetId.client); this.LinkedList.deleteNode(targetNodeId); - } - this.clock = Math.max(this.clock, clock) + 1; + this.clock = Math.max(this.clock, clock) + 1; + }); + } + + remoteUpdate(block: Block, pageId: string): void { + this.executeOperation(() => { + const updatedBlock = this.LinkedList.nodeMap[JSON.stringify(block.id)]; + updatedBlock.animation = block.animation; + updatedBlock.icon = block.icon; + updatedBlock.indent = block.indent; + updatedBlock.style = block.style; + updatedBlock.type = block.type; + updatedBlock.listIndex = block.listIndex || undefined; + return { type: "blockUpdate", node: updatedBlock, pageId }; + }); } localReorder(params: { @@ -158,21 +206,23 @@ export class EditorCRDT extends CRDT { afterId: BlockId | null; pageId: string; }): RemoteBlockReorderOperation { - const operation: RemoteBlockReorderOperation = { - ...params, - type: "blockReorder", - clock: this.clock, - client: this.client, - }; + return this.executeOperation(() => { + const operation: RemoteBlockReorderOperation = { + ...params, + type: "blockReorder", + clock: this.clock, + client: this.client, + }; + + this.LinkedList.reorderNodes({ + targetId: params.targetId, + beforeId: params.beforeId, + afterId: params.afterId, + }); + this.clock += 1; - this.LinkedList.reorderNodes({ - targetId: params.targetId, - beforeId: params.beforeId, - afterId: params.afterId, + return operation; }); - this.clock += 1; - - return operation; } remoteReorder(operation: RemoteBlockReorderOperation): void { @@ -195,8 +245,10 @@ export class EditorCRDT extends CRDT { } deserialize(data: any): void { - super.deserialize(data); - this.currentBlock = data.currentBlock ? Block.deserialize(data.currentBlock) : null; + this.executeOperation(() => { + super.deserialize(data); + this.currentBlock = data.currentBlock ? Block.deserialize(data.currentBlock) : null; + }); } } @@ -218,119 +270,118 @@ export class BlockCRDT extends CRDT { color?: TextColorType, backgroundColor?: BackgroundColorType, ): RemoteCharInsertOperation { - const id = new CharId(this.clock + 1, this.client); - const { node } = this.LinkedList.insertAtIndex(index, value, id) as { node: Char }; - if (style && style.length > 0) { - node.style = style; - } - if (color) { - node.color = color; - } - if (backgroundColor) { - node.backgroundColor = backgroundColor; - } - this.clock += 1; - const operation: RemoteCharInsertOperation = { - type: "charInsert", - node, - blockId, - pageId, - style: node.style || [], - color: node.color, - backgroundColor: node.backgroundColor, - }; - - return operation; + return this.executeOperation(() => { + const id = new CharId(this.clock + 1, this.client); + const { node } = this.LinkedList.insertAtIndex(index, value, id) as { node: Char }; + + if (style && style.length > 0) { + node.style = style; + } + if (color) { + node.color = color; + } + if (backgroundColor) { + node.backgroundColor = backgroundColor; + } + + this.clock += 1; + + return { + type: "charInsert", + node, + blockId, + pageId, + style: node.style || [], + color: node.color, + backgroundColor: node.backgroundColor, + }; + }); } localDelete(index: number, blockId: BlockId, pageId: string): RemoteCharDeleteOperation { - if (index < 0 || index >= this.LinkedList.spread().length) { - throw new Error(`Invalid index: ${index}`); - } - - const nodeToDelete = this.LinkedList.findByIndex(index); - if (!nodeToDelete) { - throw new Error(`Node not found at index: ${index}`); - } - - const operation: RemoteCharDeleteOperation = { - type: "charDelete", - targetId: nodeToDelete.id, - clock: this.clock, - blockId, - pageId, - }; - - this.LinkedList.deleteNode(nodeToDelete.id); - this.clock += 1; - - return operation; + return this.executeOperation(() => { + const nodeToDelete = this.LinkedList.findByIndex(index); + const operation: RemoteCharDeleteOperation = { + type: "charDelete", + targetId: nodeToDelete.id, + clock: this.clock, + blockId, + pageId, + }; + + this.LinkedList.deleteNode(nodeToDelete.id); + this.clock += 1; + + return operation; + }); } localUpdate(node: Char, blockId: BlockId, pageId: string): RemoteCharUpdateOperation { - const updatedChar = this.LinkedList.nodeMap[JSON.stringify(node.id)]; - if (node.style && node.style.length > 0) { - updatedChar.style = [...node.style]; - } - if (node.color) { - updatedChar.color = node.color; - } - if (node.backgroundColor !== updatedChar.backgroundColor) { - updatedChar.backgroundColor = node.backgroundColor; - } - return { type: "charUpdate", node: updatedChar, blockId, pageId }; + return this.executeOperation(() => { + const updatedChar = this.LinkedList.nodeMap[JSON.stringify(node.id)]; + if (node.style && node.style.length > 0) { + updatedChar.style = [...node.style]; + } + if (node.color) { + updatedChar.color = node.color; + } + if (node.backgroundColor !== updatedChar.backgroundColor) { + updatedChar.backgroundColor = node.backgroundColor; + } + + return { type: "charUpdate", node: updatedChar, blockId, pageId }; + }); } remoteInsert(operation: RemoteCharInsertOperation): void { - const newNodeId = new CharId(operation.node.id.clock, operation.node.id.client); - const newNode = new Char(operation.node.value, newNodeId); + this.executeOperation(() => { + const newNodeId = new CharId(operation.node.id.clock, operation.node.id.client); + const newNode = new Char(operation.node.value, newNodeId); - newNode.next = operation.node.next; - newNode.prev = operation.node.prev; + newNode.next = operation.node.next; + newNode.prev = operation.node.prev; - if (operation.style && operation.style.length > 0) { - operation.style.forEach((style) => { - newNode.style.push(style); - }); - } + if (operation.style && operation.style.length > 0) { + operation.style.forEach((style) => { + newNode.style.push(style); + }); + } - if (operation.color) { - newNode.color = operation.color; - } - - if (operation.backgroundColor) { - newNode.backgroundColor = operation.backgroundColor; - } + if (operation.color) { + newNode.color = operation.color; + } - this.LinkedList.insertById(newNode); + if (operation.backgroundColor) { + newNode.backgroundColor = operation.backgroundColor; + } - if (this.clock <= newNode.id.clock) { - this.clock = newNode.id.clock + 1; - } + this.LinkedList.insertById(newNode); + this.clock = Math.max(this.clock, newNode.id.clock) + 1; + }); } remoteDelete(operation: RemoteCharDeleteOperation): void { - const { targetId, clock } = operation; - if (targetId) { + this.executeOperation(() => { + const { clock } = operation; const targetNodeId = new CharId(operation.targetId.clock, operation.targetId.client); this.LinkedList.deleteNode(targetNodeId); - } - if (this.clock <= clock) { - this.clock = clock + 1; - } + this.clock = Math.max(this.clock, clock) + 1; + }); } remoteUpdate(operation: RemoteCharUpdateOperation): void { - const updatedChar = this.LinkedList.nodeMap[JSON.stringify(operation.node.id)]; - if (operation.node.style && operation.node.style.length > 0) { - updatedChar.style = [...operation.node.style]; - } - if (operation.node.color) { - updatedChar.color = operation.node.color; - } - if (operation.node.backgroundColor) { - updatedChar.backgroundColor = operation.node.backgroundColor; - } + this.executeOperation(() => { + const updatedChar = this.LinkedList.nodeMap[JSON.stringify(operation.node.id)]; + if (operation.node.style?.length > 0) { + updatedChar.style = [...operation.node.style]; + } + if (operation.node.color) { + updatedChar.color = operation.node.color; + } + if (operation.node.backgroundColor) { + updatedChar.backgroundColor = operation.node.backgroundColor; + } + }); } serialize(): CRDTSerializedProps { @@ -349,7 +400,9 @@ export class BlockCRDT extends CRDT { } deserialize(data: any): void { - super.deserialize(data); - this.currentCaret = data.currentCaret; + this.executeOperation(() => { + super.deserialize(data); + this.currentCaret = data.currentCaret ?? 0; + }); } } diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index 759c07a0..87a06b2a 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -1,6 +1,6 @@ import { Node, Char, Block } from "./Node"; import { NodeId, BlockId, CharId } from "./NodeId"; -import { InsertOperation, ReorderNodesProps } from "./Interfaces"; +import { ReorderNodesProps } from "./Interfaces"; export abstract class LinkedList> { head: T["id"] | null; @@ -20,35 +20,34 @@ export abstract class LinkedList> { this.nodeMap[JSON.stringify(id)] = node; } - getNode(id: T["id"] | null): T | null { - if (!id) return null; - return this.nodeMap[JSON.stringify(id)] || null; + getNode(id: T["id"] | null): T { + if (!id) { + throw new Error(`Invalid node id: ${id}`); + } + const node = this.nodeMap[JSON.stringify(id)]; + if (!node) { + throw new Error(`Node not found: ${JSON.stringify(id)}`); + } + return node; } deleteNode(id: T["id"]): void { const nodeToDelete = this.getNode(id); - if (!nodeToDelete) return; if (this.head && id.equals(this.head)) { this.head = nodeToDelete.next; if (nodeToDelete.next) { const nextNode = this.getNode(nodeToDelete.next); - if (nextNode) { - nextNode.prev = null; - } + nextNode.prev = null; } } else { if (nodeToDelete.prev) { const prevNode = this.getNode(nodeToDelete.prev); - if (prevNode) { - prevNode.next = nodeToDelete.next; - if (nodeToDelete.next) { - const nextNode = this.getNode(nodeToDelete.next); - if (nextNode) { - nextNode.prev = nodeToDelete.prev; - } - } - } + prevNode.next = nodeToDelete.next; + } + if (nodeToDelete.next) { + const nextNode = this.getNode(nodeToDelete.next); + nextNode.prev = nodeToDelete.prev; } } @@ -67,97 +66,78 @@ export abstract class LinkedList> { let currentNodeId = this.head; let currentIndex = 0; - while (currentNodeId !== null && currentIndex < index) { + const visitedNodes = new Set(); + + while (currentNodeId !== null && currentIndex <= index) { + if (visitedNodes.has(JSON.stringify(currentNodeId))) { + throw new Error("Circular reference detected."); + } + visitedNodes.add(JSON.stringify(currentNodeId)); + const currentNode = this.getNode(currentNodeId); - if (!currentNode) { - throw new Error(`Node not found at index ${currentIndex}`); + if (currentIndex === index) { + return currentNode; } currentNodeId = currentNode.next; currentIndex += 1; } - if (currentNodeId === null) { - throw new Error(`LinkedList is empty at index ${index}`); - } - - const node = this.getNode(currentNodeId); - if (!node) { - throw new Error(`Node not found at index ${index}`); - } - - return node; + throw new Error(`Index out of bounds: ${index}`); } insertAtIndex(index: number, value: string, id: T["id"]) { - try { - const node = this.createNode(value, id); - this.setNode(id, node); - - if (!this.head || index <= 0) { - node.next = this.head; - node.prev = null; - if (this.head) { - const oldHead = this.getNode(this.head); - if (oldHead) { - oldHead.prev = id; - } - } - - this.head = id; - return { node }; - } - - const prevNode = this.findByIndex(index - 1); - node.next = prevNode.next; - prevNode.next = id; - node.prev = prevNode.id; - - if (node.next) { - const nextNode = this.getNode(node.next); - if (nextNode) { - nextNode.prev = id; - } - } - - return { node }; - } catch (e) { - throw new Error(`InsertAtIndex failed: ${e}`); + if (index < 0) { + throw new Error(`Invalid negative index: ${index}`); } - } - insertById(node: T): void { - if (this.getNode(node.id)) return; + const node = this.createNode(value, id); + this.setNode(id, node); - if (!node.prev) { + if (!this.head || index <= 0) { node.next = this.head; node.prev = null; - if (this.head) { const oldHead = this.getNode(this.head); - if (oldHead) { - oldHead.prev = node.id; - } + oldHead.prev = id; } - this.head = node.id; - this.setNode(node.id, node); - return; - } - - const prevNode = this.getNode(node.prev); - if (!prevNode) { - throw new Error(`Previous node not found: ${JSON.stringify(node.prev)}`); + this.head = id; + return { node }; } + const prevNode = this.findByIndex(index - 1); node.next = prevNode.next; node.prev = prevNode.id; - prevNode.next = node.id; + prevNode.next = id; if (node.next) { const nextNode = this.getNode(node.next); - if (nextNode) { + nextNode.prev = id; + } + + return { node }; + } + + insertById(node: T): void { + if (this.nodeMap[JSON.stringify(node.id)]) return; + + if (node.prev) { + const prevNode = this.getNode(node.prev); + node.next = prevNode.next; + prevNode.next = node.id; + + if (node.next) { + const nextNode = this.getNode(node.next); nextNode.prev = node.id; } + } else { + node.next = this.head; + node.prev = null; + if (this.head) { + const oldHead = this.getNode(this.head); + oldHead.prev = node.id; + } + this.head = node.id; } this.setNode(node.id, node); @@ -165,28 +145,32 @@ export abstract class LinkedList> { getNodesBetween(startIndex: number, endIndex: number): T[] { if (startIndex < 0 || endIndex < startIndex) { - throw new Error("Invalid indices"); + throw new Error(`Invalid indices: startIndex=${startIndex}, endIndex=${endIndex}`); } const result: T[] = []; let currentNodeId = this.head; let currentIndex = 0; + const visitedNodes = new Set(); + // 시작 인덱스까지 이동 - while (currentNodeId !== null && currentIndex < startIndex) { + while (currentNodeId !== null && currentIndex < endIndex) { + if (visitedNodes.has(JSON.stringify(currentNodeId))) { + throw new Error("Circular reference detected."); + } + visitedNodes.add(JSON.stringify(currentNodeId)); + const currentNode = this.getNode(currentNodeId); - if (!currentNode) break; + if (currentIndex >= startIndex) { + result.push(currentNode); + } currentNodeId = currentNode.next; currentIndex += 1; } - // 시작 인덱스부터 끝 인덱스까지의 노드들 수집 - while (currentNodeId !== null && currentIndex < endIndex) { - const currentNode = this.getNode(currentNodeId); - if (!currentNode) break; - result.push(currentNode); - currentNodeId = currentNode.next; - currentIndex += 1; + if (currentIndex < endIndex) { + throw new Error(`End index out of bounds: ${endIndex}`); } return result; @@ -196,9 +180,15 @@ export abstract class LinkedList> { let currentNodeId = this.head; let result = ""; + const visitedNodes = new Set(); + while (currentNodeId !== null) { + if (visitedNodes.has(JSON.stringify(currentNodeId))) { + throw new Error("Circular reference detected."); + } + visitedNodes.add(JSON.stringify(currentNodeId)); + const currentNode = this.getNode(currentNodeId); - if (!currentNode) break; result += currentNode.value; currentNodeId = currentNode.next; } @@ -209,10 +199,17 @@ export abstract class LinkedList> { spread(): T[] { let currentNodeId = this.head; const result: T[] = []; + + const visitedNodes = new Set(); + while (currentNodeId !== null) { + if (visitedNodes.has(JSON.stringify(currentNodeId))) { + throw new Error("Circular reference detected."); + } + visitedNodes.add(JSON.stringify(currentNodeId)); + const currentNode = this.getNode(currentNodeId); - if (!currentNode) break; - result.push(currentNode!); + result.push(currentNode); currentNodeId = currentNode.next; } return result; @@ -244,10 +241,18 @@ export abstract class LinkedList> { export class BlockLinkedList extends LinkedList { updateAllOrderedListIndices() { - let currentNode = this.getNode(this.head); + let currentNodeId = this.head; let currentIndex = 1; - while (currentNode) { + const visitedNodes = new Set(); + + while (currentNodeId) { + if (visitedNodes.has(JSON.stringify(currentNodeId))) { + throw new Error("Circular reference detected."); + } + visitedNodes.add(JSON.stringify(currentNodeId)); + + const currentNode = this.getNode(currentNodeId); if (currentNode.type === "ol") { const prevNode = currentNode.prev ? this.getNode(currentNode.prev) : null; @@ -274,39 +279,36 @@ export class BlockLinkedList extends LinkedList { } if (prevSameIndentNode && prevSameIndentNode.type === "ol") { - currentIndex = prevSameIndentNode.listIndex! + 1; + currentIndex = (prevSameIndentNode.listIndex || 0) + 1; } else { currentIndex = 1; } } } else { - // 같은 indent의 연속된 ol인 경우 번호 증가 - currentIndex = prevNode.listIndex!; - currentIndex += 1; + currentIndex = (prevNode.listIndex || 0) + 1; } currentNode.listIndex = currentIndex; } - currentNode = currentNode.next ? this.getNode(currentNode.next) : null; + currentNodeId = currentNode.next; } } reorderNodes({ targetId, beforeId, afterId }: ReorderNodesProps) { const targetNode = this.getNode(targetId); - if (!targetNode) return; // 1. 현재 위치에서 노드 제거 if (targetNode.prev) { const prevNode = this.getNode(targetNode.prev); - if (prevNode) prevNode.next = targetNode.next; + prevNode.next = targetNode.next; } else { this.head = targetNode.next; } if (targetNode.next) { const nextNode = this.getNode(targetNode.next); - if (nextNode) nextNode.prev = targetNode.prev; + nextNode.prev = targetNode.prev; } if (this.head === targetId) { @@ -314,6 +316,10 @@ export class BlockLinkedList extends LinkedList { } // 2. 새로운 위치에 노드 삽입 + if (!beforeId && !afterId) { + throw new Error("Either beforeId or afterId must be provided."); + } + if (!beforeId) { // 맨 앞으로 이동 const oldHead = this.head; @@ -323,7 +329,7 @@ export class BlockLinkedList extends LinkedList { if (oldHead) { const headNode = this.getNode(oldHead); - if (headNode) headNode.prev = targetId; + headNode.prev = targetId; } } else if (!afterId) { // 맨 끝으로 이동