diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts index d6fcbf3b..08daaf12 100644 --- a/@noctaCrdt/Crdt.ts +++ b/@noctaCrdt/Crdt.ts @@ -1,136 +1,111 @@ import { LinkedList } from "./LinkedList"; -import { NodeId, Node } from "./Node"; -import { - RemoteInsertOperation, - RemoteDeleteOperation, - SerializedProps, - Block, - Char, - CRDT as CRDTClassProps, -} from "./Interfaces"; - -export class CRDT implements CRDTClassProps { +import { CharId, BlockId, NodeId } from "./NodeId"; +import { Node, Char, Block } from "./Node"; +import { RemoteDeleteOperation, RemoteInsertOperation, SerializedProps } from "./Interfaces"; + +export class CRDT> { clock: number; client: number; - LinkedList: LinkedList; + LinkedList: LinkedList; constructor(client: number) { - this.clock = 0; // 이 CRDT의 논리적 시간 설정 + this.clock = 0; this.client = client; - this.LinkedList = new LinkedList(); + this.LinkedList = new LinkedList(); } - /** - * 로컬에서 삽입 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. - * @param index 삽입할 인덱스 - * @param value 삽입할 값 - * @returns 원격에 전파할 삽입 연산 객체 - */ localInsert(index: number, value: string): RemoteInsertOperation { - const id = new NodeId((this.clock += 1), this.client); + const id = + this instanceof BlockCRDT + ? new CharId(this.clock + 1, this.client) + : new BlockId(this.clock + 1, this.client); + const remoteInsertion = this.LinkedList.insertAtIndex(index, value, id); + this.clock += 1; return { node: remoteInsertion.node }; } - /** - * 로컬에서 삭제 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. - * @param index 삭제할 인덱스 - * @returns 원격에 전파할 삭제 연산 객체 - */ localDelete(index: number): RemoteDeleteOperation { - // 유효한 인덱스인지 확인 if (index < 0 || index >= this.LinkedList.spread().length) { - throw new Error(`유효하지 않은 인덱스입니다: ${index}`); + throw new Error(`Invalid index: ${index}`); } - // 삭제할 노드 찾기 const nodeToDelete = this.LinkedList.findByIndex(index); if (!nodeToDelete) { - throw new Error(`삭제할 노드를 찾을 수 없습니다. 인덱스: ${index}`); + throw new Error(`Node not found at index: ${index}`); } - // 삭제 연산 객체 생성 const operation: RemoteDeleteOperation = { targetId: nodeToDelete.id, clock: this.clock + 1, }; - // 로컬 삭제 수행 this.LinkedList.deleteNode(nodeToDelete.id); - - // 클록 업데이트 this.clock += 1; return operation; } - /** - * 원격에서 삽입 연산을 수신했을 때 처리합니다. - * @param operation 원격 삽입 연산 객체 - */ remoteInsert(operation: RemoteInsertOperation): void { - const newNodeId = new NodeId(operation.node.id.clock, operation.node.id.client); - const newNode = new Node(operation.node.value, newNodeId); + const NodeIdClass = this instanceof BlockCRDT ? CharId : BlockId; + const NodeClass = this instanceof BlockCRDT ? Char : Block; + + const newNodeId = new NodeIdClass(operation.node.id.clock, operation.node.id.client); + const newNode = new NodeClass(operation.node.value, newNodeId) as T; newNode.next = operation.node.next; newNode.prev = operation.node.prev; + this.LinkedList.insertById(newNode); - // 동기화 논리적 시간 + if (this.clock <= newNode.id.clock) { this.clock = newNode.id.clock + 1; } } - /** - * 원격에서 삭제 연산을 수신했을때 처리합니다. - * @param operation 원격 삭제 연산 객체 - */ remoteDelete(operation: RemoteDeleteOperation): void { const { targetId, clock } = operation; if (targetId) { this.LinkedList.deleteNode(targetId); } - // 동기화 논리적 시간 if (this.clock <= clock) { this.clock = clock + 1; } } - /** - * 현재 텍스트를 문자열로 반환합니다. - * @returns 현재 텍스트 - */ read(): string { return this.LinkedList.stringify(); } - /** - * 현재 텍스트를 배열로 반환합니다. - * @returns 현재 텍스트 배열 - */ - spread(): Block[] | Char[] { + spread(): T[] { return this.LinkedList.spread(); } - /** - * textLinkedList를 반환하는 getter 메서드 - * @returns LinkedList 인스턴스 - */ - public getTextLinkedList(): LinkedList { - return this.LinkedList; - } - - /** - * CRDT의 상태를 직렬화 가능한 객체로 반환합니다. - * @returns 직렬화 가능한 CRDT 상태 - */ - serialize(): SerializedProps { + serialize(): SerializedProps { return { clock: this.clock, client: this.client, - textLinkedList: { + LinkedList: { head: this.LinkedList.head, nodeMap: this.LinkedList.nodeMap, }, }; } } + +export class EditorCRDT extends CRDT { + currentBlock: Block | null; + + constructor(client: number) { + super(client); + this.currentBlock = null; + } +} + +export class BlockCRDT extends CRDT { + currentCaret: number; + + constructor(client: number) { + super(client); + this.currentCaret = 0; + } +} diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index e6bd05c8..187800ff 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -1,21 +1,23 @@ -import { NodeId, Node } from "./Node"; +import { NodeId, BlockId, CharId } from "./NodeId"; +import { Block, Char } from "./Node"; export type ElementType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "li" | "checkbox" | "blockquote"; export interface InsertOperation { - node: Node; + node: Block | Char; } export interface DeleteOperation { - targetId: NodeId | null; + targetId: BlockId | CharId; clock: number; } + export interface RemoteInsertOperation { - node: Node; + node: Block | Char; } export interface RemoteDeleteOperation { - targetId: NodeId | null; + targetId: NodeId; clock: number; } @@ -24,65 +26,53 @@ export interface CursorPosition { position: number; } -export interface SerializedProps { +export interface SerializedProps { + // CRDT 직렬화라서 이름바꿔야함. clock: number; client: number; - textLinkedList: { + LinkedList: { head: NodeId | null; - nodeMap: { [key: string]: Node }; + nodeMap: { [key: string]: T }; }; } -export interface WorkSpace { - id: string; - pageList: Page[]; - authUser: object; -} - -export interface Page { - id: string; - title: string; - icon: string; // 추후 수정 - crdt: CRDT; -} - -export interface CRDT { - clock: number; - client: number; - LinkedList: LinkedList; - localInsert(index: number, value: string): RemoteInsertOperation; - localDelete(index: number): RemoteDeleteOperation; - remoteInsert(operation: RemoteInsertOperation): void; - remoteDelete(operation: RemoteDeleteOperation): void; - read(): string; - spread(): Block[] | Char[]; -} +// export interface WorkSpace { +// id: string; +// pageList: Page[]; +// authUser: object; +// } -export interface LinkedList { - head: NodeId | null; - nodeMap: { [key: string]: Block | Char }; -} +// export interface Page { +// id: string; +// title: string; +// icon: string; // 추후 수정 +// crdt: EditorCRDT; +// } +// export interface LinkedList { +// head: NodeId | null; +// nodeMap: { [key: string]: Block | Char }; +// } -export interface Block { - id: BlockId; - icon: string; // 추후 수정 - type: ElementType; - animation: string; - crdt: CRDT; - indent: number; - next: NodeId; - prev: NodeId; - style: string[]; -} +// export interface Block { +// id: BlockId; +// icon: string; // 추후 수정 +// type: ElementType; +// animation: string; +// crdt: BlockCRDT; +// indent: number; +// next: NodeId; +// prev: NodeId; +// style: string[]; +// } -export interface Char { - id: NodeId; - value: string; - next: NodeId | null; - prev: NodeId | null; -} +// export interface Char { +// id: NodeId; +// value: string; +// next: NodeId | null; +// prev: NodeId | null; +// } -export interface BlockId { - clock: number; - client: number; -} +// export interface BlockId { +// clock: number; +// client: number; +// } diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts index 6b252974..b58c0037 100644 --- a/@noctaCrdt/LinkedList.ts +++ b/@noctaCrdt/LinkedList.ts @@ -1,11 +1,12 @@ -import { NodeId, Node } from "./Node"; +import { Node, Char, Block } from "./Node"; +import { NodeId } from "./NodeId"; import { InsertOperation } from "./Interfaces"; -export class LinkedList { - head: NodeId | null; - nodeMap: { [key: string]: Node }; +export class LinkedList> { + head: T["id"] | null; + nodeMap: { [key: string]: T }; - constructor(initialStructure?: LinkedList) { + constructor(initialStructure?: LinkedList) { if (initialStructure) { this.head = initialStructure.head; this.nodeMap = { ...initialStructure.nodeMap }; @@ -15,24 +16,20 @@ export class LinkedList { } } - // 노드맵에 노드 추가 메소드 - setNode(id: NodeId, node: Node): void { + setNode(id: T["id"], node: T): void { this.nodeMap[JSON.stringify(id)] = node; } - // 노드맵에서 노드 조회 메서드 - getNode(id: NodeId | null): Node | null { + getNode(id: T["id"] | null): T | null { if (!id) return null; return this.nodeMap[JSON.stringify(id)] || null; } - // 링크드 리스트에서 노드를 제거하고 nodeMap에서 삭제 - deleteNode(id: NodeId): void { + deleteNode(id: T["id"]): void { const nodeToDelete = this.getNode(id); if (!nodeToDelete) return; - // 삭제할 노드가 헤드인 경우 - if (this.head && this.head.equals(id)) { + if (this.head && id.equals(this.head)) { this.head = nodeToDelete.next; if (nodeToDelete.next) { const nextNode = this.getNode(nodeToDelete.next); @@ -41,7 +38,6 @@ export class LinkedList { } } } else { - // 삭제할 노드의 이전 노드를 찾아 연결을 끊는다. if (nodeToDelete.prev) { const prevNode = this.getNode(nodeToDelete.prev); if (prevNode) { @@ -56,18 +52,12 @@ export class LinkedList { } } - // nodeMap에서 노드 삭제 delete this.nodeMap[JSON.stringify(id)]; } - /** - * 링크드 리스트 안에 특정 인덱스에 해당하는 노드를 찾습니다. - * @param index 찾을 인덱스 (0-부터 출발한다.) - * @returns 해당 인덱스의 노드 - */ - findByIndex(index: number): Node { + findByIndex(index: number): T { if (index < 0) { - throw new Error(`링크드 리스트에서 특정 인덱스${index}가 음수가 입력되었습니다.`); + throw new Error(`Invalid negative index: ${index}`); } let currentNodeId = this.head; @@ -76,41 +66,30 @@ export class LinkedList { while (currentNodeId !== null && currentIndex < index) { const currentNode = this.getNode(currentNodeId); if (!currentNode) { - throw new Error( - `링크드 리스트에서 특정 인덱스에 해당하는 노드를 찾다가 에러가 발생했습니다. ${currentIndex}`, - ); + throw new Error(`Node not found at index ${currentIndex}`); } currentNodeId = currentNode.next; currentIndex += 1; } - // 유효성 검사 if (currentNodeId === null) { - throw new Error(`링크드 리스트에서 ${index}를 조회했지만 링크드 리스트가 비어있습니다. `); + throw new Error(`LinkedList is empty at index ${index}`); } + const node = this.getNode(currentNodeId); if (!node) { - throw new Error(`링크드 리스트에서 인덱스 ${index}에서 노드를 가져오지 못했습니다. `); + throw new Error(`Node not found at index ${index}`); } return node; } - /** - * 인덱스를 기반으로 노드를 삽입합니다. - * 글자를 작성할때 특정 인덱스에 삽입해야 하기 때문. - * @param index 삽입할 인덱스 (0-based) - * @param value 삽입할 값 - * @param id 삽입할 노드의 식별자 - * @returns 삽입된 노드 - */ - insertAtIndex(index: number, value: string, id: NodeId): InsertOperation { + insertAtIndex(index: number, value: string, id: T["id"]): InsertOperation { try { - const node = new Node(value, id); + const node = new Node(value, id) as T; this.setNode(id, node); - // 헤드에 삽입하는 경우 - if (!this.head || index === -1) { + if (!this.head || index <= 0) { node.next = this.head; node.prev = null; if (this.head) { @@ -124,14 +103,11 @@ export class LinkedList { return { node }; } - // 삽입할 위치의 이전 노드 찾기 const prevNode = this.findByIndex(index - 1); - node.next = prevNode.next; prevNode.next = id; node.prev = prevNode.id; - // 노드의 다음께 있으면 node를 얻고 다음 노드의 prev가 새로 추가된 노드로 업데이트 if (node.next) { const nextNode = this.getNode(node.next); if (nextNode) { @@ -141,23 +117,13 @@ export class LinkedList { return { node }; } catch (e) { - throw new Error(`링크드 리스트 내에서 insertAtIndex 실패\n${e}`); + throw new Error(`InsertAtIndex failed: ${e}`); } } - /** - * 원격 삽입 연산을 처리합니다. - * 원격 연산이 왔을때는 이미 node정보가 완성된 상태로 수신하여 큰 연산이 필요 없다. - * @param node 삽입할 노드 객체 - * @returns 수정된 인덱스 (선택사항) - */ - insertById(node: Node): void { - // 이미 존재하는 노드라면 무시 - if (this.getNode(node.id)) { - return; - } + insertById(node: T): void { + if (this.getNode(node.id)) return; - // 노드의 prev가 null이면 헤드에 삽입 if (!node.prev) { node.next = this.head; node.prev = null; @@ -174,22 +140,15 @@ export class LinkedList { return; } - // 삽입할 위치의 이전 노드 찾기 const prevNode = this.getNode(node.prev); if (!prevNode) { - throw new Error( - `원격 삽입 시, 이전 노드를 찾을 수 없습니다. prevId: ${JSON.stringify(node.prev)}`, - ); + throw new Error(`Previous node not found: ${JSON.stringify(node.prev)}`); } - // 새 노드의 다음을 이전 노드의 다음으로 설정 node.next = prevNode.next; node.prev = prevNode.id; - - // 이전 노드의 다음을 새 노드로 설정 prevNode.next = node.id; - // 새 노드의 다음 노드가 있다면, 그 노드의 prev를 새 노드로 업데이트 if (node.next) { const nextNode = this.getNode(node.next); if (nextNode) { @@ -197,14 +156,9 @@ export class LinkedList { } } - // 새 노드를 nodeMap에 추가 this.setNode(node.id, node); } - /** - * 현재 리스트를 문자열로 변환합니다. - * @returns 링크드 리스트를 순회하여 얻은 문자열 - */ stringify(): string { let currentNodeId = this.head; let result = ""; @@ -219,21 +173,19 @@ export class LinkedList { return result; } - /** - * 현재 리스트를 배열로 변환합니다. - * @returns 배열로 변환된 리스트 - */ - spread(): Node[] { + spread(): T[] { let currentNodeId = this.head; - const result: Node[] = []; - + const result: T[] = []; while (currentNodeId !== null) { const currentNode = this.getNode(currentNodeId); if (!currentNode) break; result.push(currentNode); currentNodeId = currentNode.next; } - return result; } } + +export class BlockLinkedList extends LinkedList {} + +export class TextLinkedList extends LinkedList {} diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts index 2b874457..1aad93fd 100644 --- a/@noctaCrdt/Node.ts +++ b/@noctaCrdt/Node.ts @@ -1,37 +1,21 @@ -export class NodeId { - clock: number; - client: number; +import { NodeId, BlockId, CharId } from "./NodeId"; +import { BlockCRDT } from "./Crdt"; +import { ElementType } from "./Interfaces"; - constructor(clock: number, client: number) { - this.clock = clock; - this.client = client; - } - - equals(other: NodeId): boolean { - return this.clock === other.clock && this.client === other.client; - } -} - -export class Node { - id: NodeId; +export class Node { + id: T; value: string; - next: NodeId | null; - prev: NodeId | null; + next: T | null; + prev: T | null; - constructor(value: string, id: NodeId) { + constructor(value: string, id: T) { this.id = id; this.value = value; this.next = null; this.prev = null; } - /** - * 두 노드의 순서를 비교하여, 이 노드가 다른 노드보다 먼저 와야 하는지 여부를 반환합니다. - * @param node 비교할 노드 - * @returns 순서 결정 결과 - */ - precedes(node: Node): boolean { - // prev가 다르면 비교 불가 + precedes(node: Node): boolean { if (!this.prev || !node.prev) return false; if (!this.prev.equals(node.prev)) return false; @@ -41,3 +25,28 @@ export class Node { return false; } } + +export class Block extends Node { + type: ElementType; + indent: number; + animation: string; + style: string[]; + icon: string; + crdt: BlockCRDT; + + constructor(value: string, id: BlockId) { + super(value, id); + this.type = "p"; + this.indent = 0; + this.animation = ""; + this.style = []; + this.icon = ""; + this.crdt = new BlockCRDT(id.client); + } +} + +export class Char extends Node { + constructor(value: string, id: CharId) { + super(value, id); + } +} diff --git a/@noctaCrdt/NodeId.ts b/@noctaCrdt/NodeId.ts new file mode 100644 index 00000000..b2731324 --- /dev/null +++ b/@noctaCrdt/NodeId.ts @@ -0,0 +1,17 @@ +export class NodeId { + clock: number; + client: number; + + constructor(clock: number, client: number) { + this.clock = clock; + this.client = client; + } + + equals(other: NodeId): boolean { + return this.clock === other.clock && this.client === other.client; + } +} + +export class BlockId extends NodeId {} + +export class CharId extends NodeId {} diff --git a/@noctaCrdt/Page.ts b/@noctaCrdt/Page.ts index d6268e9d..989ce794 100644 --- a/@noctaCrdt/Page.ts +++ b/@noctaCrdt/Page.ts @@ -1,18 +1,16 @@ -import { Page as PageInterface } from "@noctaCrdt/Interfaces"; -import { CRDT } from "./Crdt"; +import { EditorCRDT } from "./Crdt"; -export class Page implements PageInterface { +export class Page { id: string; title: string; icon: string; - crdt: CRDT; + crdt: EditorCRDT; - constructor(editorCRDT: CRDT = new CRDT(0)) { - // 추후 수정 + constructor(editorCRDT: EditorCRDT = new EditorCRDT(0)) { + // 추후 수정 직렬화, 역직렬화 메서드 추가 this.id = "id"; this.title = "title"; this.icon = "icon"; this.crdt = editorCRDT; - // 직렬화, 역직렬화 메서드 추가 } } diff --git a/@noctaCrdt/WorkSpace.ts b/@noctaCrdt/WorkSpace.ts index f6bf80b7..252191fd 100644 --- a/@noctaCrdt/WorkSpace.ts +++ b/@noctaCrdt/WorkSpace.ts @@ -1,7 +1,6 @@ -import { WorkSpace as WorkSpaceInterface } from "./Interfaces"; import { Page } from "./Page"; -export class WorkSpace implements WorkSpaceInterface { +export class WorkSpace { id: string; pageList: Page[]; authUser: object; diff --git a/README.md b/README.md index 5c35ee85..edd38acd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,22 @@ ![Group 38 (1)](https://github.com/user-attachments/assets/a882c5c5-b205-43cc-9a16-2f5e87dbd6aa) -## 📋 프로젝트 이름 : `Nocta` + +![image](https://github.com/user-attachments/assets/ce48d2e5-ca40-43e6-8d64-0f874312f065) +
+ 배포사이트 +
+
+ + +
+ +## `Nocta` > 🌌 밤하늘의 별빛처럼, 자유로운 인터랙션 실시간 에디터 - 실시간 기록 협업 소프트웨어입니다. -## 👩‍💻 팀 이름 : `Glassmo` (글래스모) +## `Team Glassmo` - 글래스모피즘의 약자 @@ -22,6 +32,31 @@ | FE+BE | FE | BE | FE | | [@hyonun321](https://github.com/hyonun321) | [@Ludovico7](https://github.com/Ludovico7) | [@minjungw00](https://github.com/minjungw00) | [@pipisebastian](https://github.com/pipisebastian) | + +## 프로젝트 기능 소개 +### 1. 페이지 생성, 드래그 앤 드랍 + +사이드바의 페이지 추가 버튼을 통해 페이지를 생성하고 관리할 수 있습니다. + +![111111](https://github.com/user-attachments/assets/7bbbd091-a906-49b1-8043-13240bdf2f5b) + +### 2. 탭 브라우징: 최소화 최대화 리사이즈 + +각각의 문서를 탭브라우징 방식으로 관리할 수 있습니다. 크기를 조절하거나 드래그 앤 드랍을 통해 원하는 위치에 위치시킬 수 있습니다. + +![22222](https://github.com/user-attachments/assets/7355a84a-7ff5-44c5-a3d0-24840a468818) + +### 3. 실시간 마크다운 편집 + +마크다운 문법을 입력하면 실시간으로 마크다운 문법으로 변환합니다. + +![33333](https://github.com/user-attachments/assets/ffcf7fa5-9436-4e6b-b38f-6fbf9e813cb5) + +### 4.실시간 동시편집(구현중) + +하나의 문서를 여러 사용자가 동시에 편집이 가능합니다. CRDT 알고리즘을 통해 실시간 변경사항을 모든 사용자에게 반영합니다. + + ## 🔧 기술 스택 **Common** @@ -34,59 +69,37 @@ **Backend** -
+
**Infra**
-## 🚀 프로젝트 시작 가이드 +## 시스템 아키텍처 다이어그램 -**Frontend** +![image](https://github.com/user-attachments/assets/ab96462b-5f38-4dd9-9c72-984829fa873d) -
-Frontend (추후 변경) +## 🚀 프로젝트 시작 가이드 ```bash # 저장소 복제 git clone https://github.com/boostcampwm-2024/web33-boostproject.git -# 프로젝트 폴더로 이동 -cd frontend - # 의존성 설치 -npm install - -# 개발 서버 실행 -npm run dev +pnpm install # 프로덕션 빌드 -npm run build -``` - -
- -**Backend** - -
-Backend (추후 변경) - -```bash -# 프로젝트 폴더로 이동 -cd backend +pnpm run build -# 의존성 설치 -npm install - -# 개발 서버 실행 -npm run start:dev +# 프로젝트 개발 모드 실행 +# Frontend: http://localhost:5173/ +# Backend: http://localhost:3000/ +pnpm run dev -# 프로덕션 빌드 -npm run build +# 프로젝트 Docker 빌드 후 실행 (http://localhost/) +docker-compose up -d --build ``` -
- ## 🔗 프로젝트 링크 | 노션 | 디자인 | diff --git a/server/src/crdt/crdt.gateway.ts b/server/src/crdt/crdt.gateway.ts index 44f0e3c7..af08eef9 100644 --- a/server/src/crdt/crdt.gateway.ts +++ b/server/src/crdt/crdt.gateway.ts @@ -17,13 +17,13 @@ import { @WebSocketGateway({ cors: { - origin: "*", // 실제 배포 시에는 보안을 위해 적절히 설정하세요 + origin: "*", }, }) export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { private server: Server; private clientIdCounter: number = 1; - private clientMap: Map = new Map(); // socket.id -> clientId + private clientMap: Map = new Map(); constructor(private readonly crdtService: CrdtService) {} @@ -31,10 +31,6 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.server = server; } - /** - * 초기에 연결될때, 클라이언트에 숫자id및 문서정보를 송신한다. - * @param client 클라이언트 socket 정보 - */ async handleConnection(client: Socket) { console.log(`클라이언트 연결: ${client.id}`); const assignedId = (this.clientIdCounter += 1); @@ -44,37 +40,21 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.emit("document", currentCRDT); } - /** - * 연결이 끊어지면 클라이언트 맵에서 클라이언트 삭제 - * @param client 클라이언트 socket 정보 - */ handleDisconnect(client: Socket) { console.log(`클라이언트 연결 해제: ${client.id}`); this.clientMap.delete(client.id); } - /** - * 클라이언트로부터 받은 원격 삽입 연산 - * @param data 클라이언트가 송신한 Node 정보 - * @param client 클라이언트 번호 - */ @SubscribeMessage("insert") async handleInsert( @MessageBody() data: RemoteInsertOperation, @ConnectedSocket() client: Socket, ): Promise { console.log(`Insert 연산 수신 from ${client.id}:`, data); - await this.crdtService.handleInsert(data); - client.broadcast.emit("insert", data); } - /** - * 클라이언트로부터 받은 원격 삭제 연산 - * @param data 클라이언트가 송신한 Node 정보 - * @param client 클라이언트 번호 - */ @SubscribeMessage("delete") async handleDelete( @MessageBody() data: RemoteDeleteOperation, @@ -85,11 +65,6 @@ export class CrdtGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa client.broadcast.emit("delete", data); } - /** - * 추후 caret 표시 기능을 위해 받아놓음 + 추후 개선때 인덱스 계산할때 캐럿으로 계산하면 용이할듯 하여 데이터로 만듦 - * @param data 클라이언트가 송신한 caret 정보 - * @param client 클라이언트 번호 - */ @SubscribeMessage("cursor") handleCursor(@MessageBody() data: CursorPosition, @ConnectedSocket() client: Socket): void { console.log(`Cursor 위치 수신 from ${client.id}:`, data); diff --git a/server/src/crdt/crdt.module.ts b/server/src/crdt/crdt.module.ts index 5ba3e960..e9bd8244 100644 --- a/server/src/crdt/crdt.module.ts +++ b/server/src/crdt/crdt.module.ts @@ -1,7 +1,7 @@ import { Module } from "@nestjs/common"; import { CrdtService } from "./crdt.service"; import { MongooseModule } from "@nestjs/mongoose"; -import { Doc, DocumentSchema } from "../schemas/document.schema"; +import { Doc, DocumentSchema } from "./schemas/document.schema"; import { CrdtGateway } from "./crdt.gateway"; @Module({ diff --git a/server/src/crdt/crdt.service.ts b/server/src/crdt/crdt.service.ts index 552a6147..52b56106 100644 --- a/server/src/crdt/crdt.service.ts +++ b/server/src/crdt/crdt.service.ts @@ -1,52 +1,61 @@ -// src/crdt/crdt.service.ts import { Injectable, OnModuleInit } from "@nestjs/common"; import { InjectModel } from "@nestjs/mongoose"; -import { Doc, DocumentDocument } from "../schemas/document.schema"; +import { Doc, DocumentDocument } from "./schemas/document.schema"; import { Model } from "mongoose"; -import { CRDT } from "@noctaCrdt/Crdt"; -import { NodeId, Node } from "@noctaCrdt/Node"; - -interface RemoteInsertOperation { - node: Node; -} - -interface RemoteDeleteOperation { - targetId: NodeId | null; - clock: number; -} +import { BlockCRDT } from "@noctaCrdt/Crdt"; +import { RemoteInsertOperation, RemoteDeleteOperation } from "@noctaCrdt/Interfaces"; +import { CharId } from "@noctaCrdt/NodeId"; +import { Char } from "@noctaCrdt/Node"; @Injectable() export class CrdtService implements OnModuleInit { - private crdt: CRDT; + private crdt: BlockCRDT; constructor(@InjectModel(Doc.name) private documentModel: Model) { - this.crdt = new CRDT(0); // 초기 클라이언트 ID는 0으로 설정 (서버 자체) + this.crdt = new BlockCRDT(0); } async onModuleInit() { try { const doc = await this.getDocument(); + if (doc && doc.crdt) { + this.crdt = new BlockCRDT(0); + try { + // 저장된 CRDT 상태를 복원 + this.crdt.clock = doc.crdt.clock; + this.crdt.client = doc.crdt.client; - if (doc && doc.content) { - this.crdt = new CRDT(0); - let contentArray: string[]; + // LinkedList 복원 + if (doc.crdt.LinkedList.head) { + this.crdt.LinkedList.head = new CharId( + doc.crdt.LinkedList.head.clock, + doc.crdt.LinkedList.head.client, + ); + } - try { - contentArray = JSON.parse(doc.content) as string[]; + this.crdt.LinkedList.nodeMap = {}; + for (const [key, node] of Object.entries(doc.crdt.LinkedList.nodeMap)) { + const reconstructedNode = new Char( + node.value, + new CharId(node.id.clock, node.id.client), + ); + + if (node.next) { + reconstructedNode.next = new CharId(node.next.clock, node.next.client); + } + if (node.prev) { + reconstructedNode.prev = new CharId(node.prev.clock, node.prev.client); + } + + this.crdt.LinkedList.nodeMap[key] = reconstructedNode; + } } catch (e) { - console.error("Invalid JSON in document content:", doc.content); + console.error("Error reconstructing CRDT:", e); } - contentArray.forEach((char, index) => { - this.crdt.localInsert(index, char); - }); } } catch (error) { console.error("Error during CrdtService initialization:", error); } } - - /** - * MongoDB에서 문서를 가져옵니다. - */ async getDocument(): Promise { let doc = await this.documentModel.findOne(); if (!doc) { @@ -56,49 +65,32 @@ export class CrdtService implements OnModuleInit { return doc; } - /** - * MongoDB에 문서를 업데이트합니다. - */ async updateDocument(): Promise { - const content = JSON.stringify(this.crdt.spread()); + const serializedCRDT = this.crdt.serialize(); const doc = await this.documentModel.findOneAndUpdate( {}, - { content, updatedAt: new Date() }, + { crdt: serializedCRDT, updatedAt: new Date() }, { new: true, upsert: true }, ); - ("d"); - if (!doc) { - throw new Error("문서가 저장되지 않았습니다."); - } + if (!doc) throw new Error("문서 저장 실패"); return doc; } - /** - * 삽입 연산을 처리하고 문서를 업데이트합니다. - */ async handleInsert(operation: RemoteInsertOperation): Promise { this.crdt.remoteInsert(operation); await this.updateDocument(); } - /** - * 삭제 연산을 처리하고 문서를 업데이트합니다. - */ async handleDelete(operation: RemoteDeleteOperation): Promise { this.crdt.remoteDelete(operation); await this.updateDocument(); } - /** - * 현재 CRDT의 텍스트를 반환합니다. - */ getText(): string { return this.crdt.read(); } - /** - * CRDT 인스턴스를 반환하는 Getter 메서드 - */ - getCRDT(): CRDT { + + getCRDT(): BlockCRDT { return this.crdt; } } diff --git a/server/src/crdt/schemas/document.schema.ts b/server/src/crdt/schemas/document.schema.ts new file mode 100644 index 00000000..5b63d596 --- /dev/null +++ b/server/src/crdt/schemas/document.schema.ts @@ -0,0 +1,31 @@ +import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; +import { Document } from "mongoose"; + +export type DocumentDocument = Document & Doc; + +@Schema() +export class Doc { + @Prop({ type: Object, required: true }) + crdt: { + clock: number; + client: number; + LinkedList: { + head: { + clock: number; + client: number; + } | null; + nodeMap: { + [key: string]: { + id: { clock: number; client: number }; + value: string; + next: { clock: number; client: number } | null; + prev: { clock: number; client: number } | null; + }; + }; + }; + }; + + @Prop({ default: Date.now }) + updatedAt: Date; +} +export const DocumentSchema = SchemaFactory.createForClass(Doc); diff --git a/server/src/schemas/document.schema.ts b/server/src/schemas/document.schema.ts deleted file mode 100644 index 7dcdc6bc..00000000 --- a/server/src/schemas/document.schema.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; -import { Document } from "mongoose"; - -export type DocumentDocument = Document & Doc; - -@Schema() -export class Doc { - @Prop({ required: true }) - content: string; - - @Prop({ default: Date.now }) - updatedAt: Date; -} - -export const DocumentSchema = SchemaFactory.createForClass(Doc); diff --git a/server/tsconfig.json b/server/tsconfig.json index cc09eecb..f53101b9 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -30,6 +30,6 @@ "esModuleInterop": true, "resolveJsonModule": true }, - "include": ["src/**/*", "test/**/*"], + "include": ["src/**/*", "test/**/*", "schemas"], "exclude": ["node_modules", "dist"] }