From 3f5a958a06eccecb0598461764abe76c39cd6e38 Mon Sep 17 00:00:00 2001 From: Sujong Kwak Date: Thu, 14 Nov 2024 16:10:33 +0900 Subject: [PATCH] feat: Implement CRDT register and map with associated types (#64) * feat: Implement CRDT register and map with associated types * chore: Reorganize file path from crdt/core to core/crdt --- client/src/core/crdt.types.ts | 25 ++++++++ client/src/core/crdt/LWWMap.ts | 90 +++++++++++++++++++++++++++++ client/src/core/crdt/LWWRegister.ts | 35 +++++++++++ 3 files changed, 150 insertions(+) create mode 100644 client/src/core/crdt.types.ts create mode 100644 client/src/core/crdt/LWWMap.ts create mode 100644 client/src/core/crdt/LWWRegister.ts diff --git a/client/src/core/crdt.types.ts b/client/src/core/crdt.types.ts new file mode 100644 index 000000000..c8461453e --- /dev/null +++ b/client/src/core/crdt.types.ts @@ -0,0 +1,25 @@ +export type Point = { + x: number; + y: number; +}; + +export type StrokeStyle = { + color: string; + width: number; +}; + +export type Stroke = { + points: Point[]; + style: StrokeStyle; +}; + +export type RegisterState = [peerId: string, timestamp: number, value: T]; + +export type MapState = { + [key: string]: RegisterState; +}; + +export type CRDTMessage = { + type: 'sync' | 'update'; + state: MapState | { key: string; register: RegisterState }; +}; diff --git a/client/src/core/crdt/LWWMap.ts b/client/src/core/crdt/LWWMap.ts new file mode 100644 index 000000000..de728712d --- /dev/null +++ b/client/src/core/crdt/LWWMap.ts @@ -0,0 +1,90 @@ +import { MapState, RegisterState, Stroke } from '../crdt.types.ts'; +import { LWWRegister } from './LWWRegister.ts'; + +export class LWWMap { + readonly id: string; + #data: Map>; + + constructor(id: string, initialState: MapState = {}) { + this.id = id; + this.#data = new Map(); + + for (const [key, registerState] of Object.entries(initialState)) { + this.#data.set(key, new LWWRegister(this.id, registerState)); + } + } + + get state(): MapState { + const state: MapState = {}; + for (const [key, register] of this.#data.entries()) { + state[key] = register.state; + } + return state; + } + + get strokes(): { id: string; stroke: Stroke }[] { + const result = []; + for (const [key, register] of this.#data.entries()) { + const value = register.value; + if (value !== null) { + result.push({ id: key, stroke: value }); + } + } + return result; + } + + // 선 생성 + addStroke(stroke: Stroke): string { + const timestamp = Date.now(); + const id = `${this.id}-${timestamp}-${Math.random().toString(36).substring(2, 9)}`; + const register = new LWWRegister(this.id, [this.id, timestamp, stroke]); + this.#data.set(id, register); + return id; + } + + // 선 삭제 + deleteStroke(id: string): boolean { + const register = this.#data.get(id); + if (register) { + register.set(null); + return true; + } + return false; + } + + // 원격 상태 병합 + merge(remoteState: MapState): string[] { + const updatedKeys: string[] = []; + + for (const [key, remoteRegisterState] of Object.entries(remoteState)) { + const localRegister = this.#data.get(key); + + if (localRegister) { + // 기존 레지스터가 있으면 병합 + if (localRegister.merge(remoteRegisterState)) { + updatedKeys.push(key); + } + } else { + // 새로운 레지스터면 추가 + this.#data.set(key, new LWWRegister(this.id, remoteRegisterState)); + updatedKeys.push(key); + } + } + + return updatedKeys; + } + + // 단일 레지스터 업데이트 + mergeRegister(key: string, remoteRegisterState: RegisterState): boolean { + const localRegister = this.#data.get(key); + + if (localRegister) { + // 기존 stroke에 대한 원격 업데이트 병합 + return localRegister.merge(remoteRegisterState); + } else { + // 새로운 stroke 추가 + this.#data.set(key, new LWWRegister(this.id, remoteRegisterState)); + return true; + } + } +} diff --git a/client/src/core/crdt/LWWRegister.ts b/client/src/core/crdt/LWWRegister.ts new file mode 100644 index 000000000..b62f25649 --- /dev/null +++ b/client/src/core/crdt/LWWRegister.ts @@ -0,0 +1,35 @@ +import { RegisterState } from '../crdt.types'; + +export class LWWRegister { + readonly id: string; + #state: RegisterState; // [peerId, timestamp, value] + + constructor(id: string, initialState: RegisterState) { + this.id = id; + this.#state = initialState; + } + + get value(): T { + return this.#state[2]; + } + + get state(): RegisterState { + return this.#state; + } + + set(value: T): void { + this.#state = [this.id, Date.now(), value]; + } + + // 원격 상태와 병합 (더 새로운 타임스탬프 또는 더 큰 피어 ID 우선) + merge(remoteState: RegisterState): boolean { + const [remotePeer, remoteTimestamp] = remoteState; + const [localPeer, localTimestamp] = this.#state; + + if (remoteTimestamp > localTimestamp || (remoteTimestamp === localTimestamp && remotePeer > localPeer)) { + this.#state = remoteState; + return true; + } + return false; + } +}