diff --git a/.gitignore b/.gitignore index 8140d698..07237331 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /node_modules **/node_modules -/dist +*/dist /build .DS_Store .env \ No newline at end of file diff --git a/@noctaCrdt/Crdt.ts b/@noctaCrdt/Crdt.ts new file mode 100644 index 00000000..153bb8fc --- /dev/null +++ b/@noctaCrdt/Crdt.ts @@ -0,0 +1,129 @@ +import { LinkedList } from "./LinkedList"; +import { NodeId, Node } from "./Node"; +import { RemoteInsertOperation, RemoteDeleteOperation, SerializedProps } from "./Interfaces"; + +export class CRDT { + clock: number; + client: number; + textLinkedList: LinkedList; + + constructor(client: number) { + this.clock = 0; // 이 CRDT의 논리적 시간 설정 + this.client = client; + this.textLinkedList = new LinkedList(); + } + + /** + * 로컬에서 삽입 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. + * @param index 삽입할 인덱스 + * @param value 삽입할 값 + * @returns 원격에 전파할 삽입 연산 객체 + */ + localInsert(index: number, value: string): RemoteInsertOperation { + const id = new NodeId((this.clock += 1), this.client); + const remoteInsertion = this.textLinkedList.insertAtIndex(index, value, id); + return { node: remoteInsertion.node }; + } + + /** + * 로컬에서 삭제 연산을 수행하고, 원격에 전파할 연산 객체를 반환합니다. + * @param index 삭제할 인덱스 + * @returns 원격에 전파할 삭제 연산 객체 + */ + localDelete(index: number): RemoteDeleteOperation { + // 유효한 인덱스인지 확인 + if (index < 0 || index >= this.textLinkedList.spread().length) { + throw new Error(`유효하지 않은 인덱스입니다: ${index}`); + } + + // 삭제할 노드 찾기 + const nodeToDelete = this.textLinkedList.findByIndex(index); + if (!nodeToDelete) { + throw new Error(`삭제할 노드를 찾을 수 없습니다. 인덱스: ${index}`); + } + + // 삭제 연산 객체 생성 + const operation: RemoteDeleteOperation = { + targetId: nodeToDelete.id, + clock: this.clock + 1, + }; + + // 로컬 삭제 수행 + this.textLinkedList.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); + newNode.next = operation.node.next; + newNode.prev = operation.node.prev; + this.textLinkedList.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.textLinkedList.deleteNode(targetId); + } + // 동기화 논리적 시간 + if (this.clock <= clock) { + this.clock = clock + 1; + } + } + + /** + * 현재 텍스트를 문자열로 반환합니다. + * @returns 현재 텍스트 + */ + read(): string { + return this.textLinkedList.stringify(); + } + + /** + * 현재 텍스트를 배열로 반환합니다. + * @returns 현재 텍스트 배열 + */ + spread(): string[] { + return this.textLinkedList.spread(); + } + + /** + * textLinkedList를 반환하는 getter 메서드 + * @returns LinkedList 인스턴스 + */ + public getTextLinkedList(): LinkedList { + return this.textLinkedList; + } + + /** + * CRDT의 상태를 직렬화 가능한 객체로 반환합니다. + * @returns 직렬화 가능한 CRDT 상태 + */ + serialize(): SerializedProps { + return { + clock: this.clock, + client: this.client, + textLinkedList: { + head: this.textLinkedList.head, + nodeMap: this.textLinkedList.nodeMap, + }, + }; + } +} diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts new file mode 100644 index 00000000..f3359df6 --- /dev/null +++ b/@noctaCrdt/Interfaces.ts @@ -0,0 +1,32 @@ +import { NodeId, Node } from "./Node"; + +export interface InsertOperation { + node: Node; +} + +export interface DeleteOperation { + targetId: NodeId | null; + clock: number; +} +export interface RemoteInsertOperation { + node: Node; +} + +export interface RemoteDeleteOperation { + targetId: NodeId | null; + clock: number; +} + +export interface CursorPosition { + clientId: number; + position: number; +} + +export interface SerializedProps { + clock: number; + client: number; + textLinkedList: { + head: NodeId | null; + nodeMap: { [key: string]: Node }; + }; +} diff --git a/@noctaCrdt/LinkedList.ts b/@noctaCrdt/LinkedList.ts new file mode 100644 index 00000000..84b589c8 --- /dev/null +++ b/@noctaCrdt/LinkedList.ts @@ -0,0 +1,239 @@ +import { NodeId, Node } from "./Node"; +import { InsertOperation } from "./Interfaces"; + +export class LinkedList { + head: NodeId | null; + nodeMap: { [key: string]: Node }; + + constructor(initialStructure?: LinkedList) { + if (initialStructure) { + this.head = initialStructure.head; + this.nodeMap = { ...initialStructure.nodeMap }; + } else { + this.head = null; + this.nodeMap = {}; + } + } + + // 노드맵에 노드 추가 메소드 + setNode(id: NodeId, node: Node): void { + this.nodeMap[JSON.stringify(id)] = node; + } + + // 노드맵에서 노드 조회 메서드 + getNode(id: NodeId | null): Node | null { + if (!id) return null; + return this.nodeMap[JSON.stringify(id)] || null; + } + + // 링크드 리스트에서 노드를 제거하고 nodeMap에서 삭제 + deleteNode(id: NodeId): void { + const nodeToDelete = this.getNode(id); + if (!nodeToDelete) return; + + // 삭제할 노드가 헤드인 경우 + if (this.head && this.head.equals(id)) { + this.head = nodeToDelete.next; + if (nodeToDelete.next) { + const nextNode = this.getNode(nodeToDelete.next); + if (nextNode) { + 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; + } + } + } + } + } + + // nodeMap에서 노드 삭제 + delete this.nodeMap[JSON.stringify(id)]; + } + + /** + * 링크드 리스트 안에 특정 인덱스에 해당하는 노드를 찾습니다. + * @param index 찾을 인덱스 (0-부터 출발한다.) + * @returns 해당 인덱스의 노드 + */ + findByIndex(index: number): Node { + if (index < 0) { + throw new Error(`링크드 리스트에서 특정 인덱스${index}가 음수가 입력되었습니다.`); + } + + let currentNodeId = this.head; + let currentIndex = 0; + + while (currentNodeId !== null && currentIndex < index) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) { + throw new Error( + `링크드 리스트에서 특정 인덱스에 해당하는 노드를 찾다가 에러가 발생했습니다. ${currentIndex}`, + ); + } + currentNodeId = currentNode.next; + currentIndex += 1; + } + + // 유효성 검사 + if (currentNodeId === null) { + throw new Error(`링크드 리스트에서 ${index}를 조회했지만 링크드 리스트가 비어있습니다. `); + } + const node = this.getNode(currentNodeId); + if (!node) { + throw new Error(`링크드 리스트에서 인덱스 ${index}에서 노드를 가져오지 못했습니다. `); + } + + return node; + } + + /** + * 인덱스를 기반으로 노드를 삽입합니다. + * 글자를 작성할때 특정 인덱스에 삽입해야 하기 때문. + * @param index 삽입할 인덱스 (0-based) + * @param value 삽입할 값 + * @param id 삽입할 노드의 식별자 + * @returns 삽입된 노드 + */ + insertAtIndex(index: number, value: string, id: NodeId): InsertOperation { + try { + const node = new Node(value, id); + this.setNode(id, node); + + // 헤드에 삽입하는 경우 + if (!this.head || index === -1) { + 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; + + // 노드의 다음께 있으면 node를 얻고 다음 노드의 prev가 새로 추가된 노드로 업데이트 + if (node.next) { + const nextNode = this.getNode(node.next); + if (nextNode) { + nextNode.prev = id; + } + } + + return { node }; + } catch (e) { + throw new Error(`링크드 리스트 내에서 insertAtIndex 실패\n${e}`); + } + } + + /** + * 원격 삽입 연산을 처리합니다. + * 원격 연산이 왔을때는 이미 node정보가 완성된 상태로 수신하여 큰 연산이 필요 없다. + * @param node 삽입할 노드 객체 + * @returns 수정된 인덱스 (선택사항) + */ + insertById(node: Node): void { + // 이미 존재하는 노드라면 무시 + if (this.getNode(node.id)) { + return; + } + + // 노드의 prev가 null이면 헤드에 삽입 + if (!node.prev) { + node.next = this.head; + node.prev = null; + + if (this.head) { + const oldHead = this.getNode(this.head); + if (oldHead) { + oldHead.prev = node.id; + } + } + + this.head = node.id; + this.setNode(node.id, node); + return; + } + + // 삽입할 위치의 이전 노드 찾기 + const prevNode = this.getNode(node.prev); + if (!prevNode) { + throw new Error( + `원격 삽입 시, 이전 노드를 찾을 수 없습니다. prevId: ${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) { + nextNode.prev = node.id; + } + } + + // 새 노드를 nodeMap에 추가 + this.setNode(node.id, node); + } + + /** + * 현재 리스트를 문자열로 변환합니다. + * @returns 링크드 리스트를 순회하여 얻은 문자열 + */ + stringify(): string { + let currentNodeId = this.head; + let result = ""; + + while (currentNodeId !== null) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) break; + result += currentNode.value; + currentNodeId = currentNode.next; + } + + return result; + } + + /** + * 현재 리스트를 배열로 변환합니다. + * @returns 배열로 변환된 리스트 + */ + spread(): string[] { + let currentNodeId = this.head; + const result: string[] = []; + + while (currentNodeId !== null) { + const currentNode = this.getNode(currentNodeId); + if (!currentNode) break; + result.push(currentNode.value); + currentNodeId = currentNode.next; + } + + return result; + } +} diff --git a/@noctaCrdt/Node.ts b/@noctaCrdt/Node.ts new file mode 100644 index 00000000..2b874457 --- /dev/null +++ b/@noctaCrdt/Node.ts @@ -0,0 +1,43 @@ +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 Node { + id: NodeId; + value: string; + next: NodeId | null; + prev: NodeId | null; + + constructor(value: string, id: NodeId) { + this.id = id; + this.value = value; + this.next = null; + this.prev = null; + } + + /** + * 두 노드의 순서를 비교하여, 이 노드가 다른 노드보다 먼저 와야 하는지 여부를 반환합니다. + * @param node 비교할 노드 + * @returns 순서 결정 결과 + */ + precedes(node: Node): boolean { + // prev가 다르면 비교 불가 + if (!this.prev || !node.prev) return false; + if (!this.prev.equals(node.prev)) return false; + + if (this.id.clock < node.id.clock) return true; + if (this.id.clock === node.id.clock && this.id.client < node.id.client) return true; + + return false; + } +} diff --git a/@noctaCrdt/package.json b/@noctaCrdt/package.json new file mode 100644 index 00000000..8abfc264 --- /dev/null +++ b/@noctaCrdt/package.json @@ -0,0 +1,27 @@ +{ + "name": "@noctaCrdt", + "version": "1.0.0", + "main": "dist/Crdt.js", + "types": "dist/Crdt.d.ts", + "scripts": { + "build": "tsc -b" + }, + "exports": { + ".": { + "types": "./dist/Crdt.d.ts", + "default": "./dist/Crdt.js" + }, + "./Node": { + "types": "./dist/Node.d.ts", + "default": "./dist/Node.js" + }, + "./LinkedList": { + "types": "./dist/LinkedList.d.ts", + "default": "./dist/LinkedList.js" + }, + "./Interfaces": { + "types": "./dist/Interfaces.d.ts", + "default": "./dist/Interfaces.js" + } + } +} diff --git a/@noctaCrdt/tsconfig.json b/@noctaCrdt/tsconfig.json new file mode 100644 index 00000000..f1f0224a --- /dev/null +++ b/@noctaCrdt/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/client/package.json b/client/package.json index 29a4a505..7381caeb 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "prepare": "panda codegen" }, "dependencies": { + "@noctaCrdt": "workspace:*", "@pandabox/panda-plugins": "^0.0.8", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/client/tsconfig.json b/client/tsconfig.json index d0a90532..daf6a7b6 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -23,9 +23,12 @@ "@components/*": ["src/components/*"], "@assets/*": ["src/assets/*"], "@features/*": ["src/features/*"], - "@styles/*": ["src/styles/*"] + "@styles/*": ["src/styles/*"], + "@noctaCrdt": ["../@noctaCrdt/dist"], + "@noctaCrdt/*": ["../@noctaCrdt/dist/*"] } }, + "references": [{ "path": "../@noctaCrdt" }], "include": ["src", "*.ts", "*.tsx", "vite.config.ts", "styled-system"], "exclude": ["node_modules"] } diff --git a/client/vite.config.ts b/client/vite.config.ts index 338943d5..5f5414bc 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from "vite"; +import path from "path"; import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [react(), tsconfigPaths()], + resolve: { + alias: { + "@noctaCrdt": path.resolve(__dirname, "../@noctaCrdt"), + }, + }, }); diff --git a/package.json b/package.json index 9914e855..01ce7c5f 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,17 @@ "lint": "eslint . --fix", "lint:client": "eslint \"client/src/**/*.{ts,tsx}\" --fix", "lint:server": "eslint \"server/src/**/*.{ts,tsx}\" --fix", + "build": "cd @noctaCrdt && pnpm build && cd .. && pnpm -r build", + "build:lib": "cd @noctaCrdt && pnpm build", + "build:client": "cd client && pnpm build", + "build:server": "cd server && pnpm build", "dev": "pnpm -r --parallel dev" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { + "@noctaCrdt": "workspace:*", "@eslint/js": "^9.14.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 789a39b5..21ab87e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@eslint/js': specifier: ^9.14.0 version: 9.14.0 + '@noctaCrdt': + specifier: workspace:* + version: link:@noctaCrdt '@typescript-eslint/eslint-plugin': specifier: ^7.18.0 version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.3.3))(eslint@8.57.1)(typescript@5.3.3) @@ -54,8 +57,13 @@ importers: specifier: ~5.3.3 version: 5.3.3 + '@noctaCrdt': {} + client: dependencies: + '@noctaCrdt': + specifier: workspace:* + version: link:../@noctaCrdt '@pandabox/panda-plugins': specifier: ^0.0.8 version: 0.0.8 @@ -132,6 +140,9 @@ importers: '@nestjs/websockets': specifier: ^10.4.7 version: 10.4.7(@nestjs/common@10.4.6(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.6)(@nestjs/platform-socket.io@10.4.7)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@noctaCrdt': + specifier: workspace:* + version: link:../@noctaCrdt mongoose: specifier: ^8.8.0 version: 8.8.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c43375c8..340a2a67 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - - 'client' - - 'server' + - "client" + - "server" + - "@noctaCrdt" diff --git a/server/nest-cli.json b/server/nest-cli.json index f9aa683b..5e07d508 100644 --- a/server/nest-cli.json +++ b/server/nest-cli.json @@ -3,6 +3,19 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "webpack": true, + "tsConfigPath": "tsconfig.json" + }, + "projects": { + "crdt": { + "type": "library", + "root": "../@noctaCrdt", + "entryFile": "Crdt", + "sourceRoot": "../@noctaCrdt", + "compilerOptions": { + "tsConfigPath": "../@noctaCrdt/tsconfig.json" + } + } } } diff --git a/server/package.json b/server/package.json index 62ffcad0..f13fb011 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "license": "UNLICENSED", "type": "commonjs", "scripts": { - "build": "nest build", + "build": "nest build --webpack --webpackPath webpack.config.js", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "start:dev": "nest start --watch", @@ -22,6 +22,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@noctaCrdt": "workspace:*", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/mongoose": "^10.1.0", diff --git a/server/tsconfig.json b/server/tsconfig.json index 9d70f891..5b93a0ad 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -1,4 +1,6 @@ { + "extend": "../tsconfig.base.json", + "references": [{ "path": "../@noctaCrdt" }], "compilerOptions": { // 기본 설정 "module": "commonjs", @@ -18,7 +20,10 @@ "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, - + "paths": { + "@Nocta/crdt": ["../@noctaCrdt/dist"], + "@Nocta/crdt/*": ["../@noctaCrdt/dist/*"] + }, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, diff --git a/server/webpack.config.js b/server/webpack.config.js new file mode 100644 index 00000000..f45982f9 --- /dev/null +++ b/server/webpack.config.js @@ -0,0 +1,25 @@ +const path = require("path"); + +module.exports = { + mode: "development", + resolve: { + extensions: [".ts", ".js"], + alias: { + "@noctaCrdt": path.resolve(__dirname, "../@noctaCrdt"), + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + use: { + loader: "ts-loader", + options: { + configFile: "tsconfig.json", + }, + }, + exclude: /node_modules/, + }, + ], + }, +};