UNPKG

@thesaasdevkit/rtc-core

Version:

Core operational transform algorithms and shared data types for RTCC

1,587 lines (1,577 loc) 62.8 kB
/** * Text-specific operations for collaborative text editing */ /** * Create an insert text operation */ function createInsertOperation(id, clientId, baseVersion, position, text, attributes) { return { id, clientId, baseVersion, type: 'text-insert', position, text, attributes, timestamp: Date.now(), }; } /** * Create a delete text operation */ function createDeleteOperation(id, clientId, baseVersion, position, length) { return { id, clientId, baseVersion, type: 'text-delete', position, length, timestamp: Date.now(), }; } /** * Create a retain text operation */ function createRetainOperation(id, clientId, baseVersion, position, length, attributes) { return { id, clientId, baseVersion, type: 'text-retain', position, length, attributes, timestamp: Date.now(), }; } /** * Helper function to check if an operation is a text operation */ function isTextOperation(operation) { return operation.type.startsWith('text-'); } /** * Helper function to check if an operation is an insert operation */ function isInsertOperation(operation) { return operation.type === 'text-insert'; } /** * Helper function to check if an operation is a delete operation */ function isDeleteOperation(operation) { return operation.type === 'text-delete'; } /** * Helper function to check if an operation is a retain operation */ function isRetainOperation(operation) { return operation.type === 'text-retain'; } /** * Operational Transformation algorithms for text operations * Based on proven OT algorithms from literature */ /** * Transform operation A against operation B * This implements the core OT transformation logic for text */ function transformTextOperation(operationA, operationB) { // Insert vs Insert if (isInsertOperation(operationA) && isInsertOperation(operationB)) { return transformInsertInsert$1(operationA, operationB); } // Insert vs Delete if (isInsertOperation(operationA) && isDeleteOperation(operationB)) { return transformInsertDelete$1(operationA, operationB); } // Delete vs Insert if (isDeleteOperation(operationA) && isInsertOperation(operationB)) { return transformDeleteInsert$1(operationA, operationB); } // Delete vs Delete if (isDeleteOperation(operationA) && isDeleteOperation(operationB)) { return transformDeleteDelete$2(operationA, operationB); } // Retain operations don't need transformation in basic text model if (isRetainOperation(operationA) || isRetainOperation(operationB)) { return { operation: operationA, wasTransformed: false }; } // Default case - no transformation needed return { operation: operationA, wasTransformed: false }; } /** * Transform Insert against Insert * When two inserts happen at the same position, we need to decide ordering */ function transformInsertInsert$1(operationA, operationB) { if (operationA.position <= operationB.position) { // A comes before or at same position as B // A's position doesn't change return { operation: operationA, wasTransformed: false }; } else { // A comes after B, so A's position shifts by B's text length const transformed = { ...operationA, position: operationA.position + operationB.text.length, }; return { operation: transformed, wasTransformed: true }; } } /** * Transform Insert against Delete * The insert position might need adjustment based on where the delete occurs */ function transformInsertDelete$1(operationA, operationB) { if (operationA.position <= operationB.position) { // Insert comes before delete - no change needed return { operation: operationA, wasTransformed: false }; } else if (operationA.position >= operationB.position + operationB.length) { // Insert comes after the deleted range - adjust position const transformed = { ...operationA, position: operationA.position - operationB.length, }; return { operation: transformed, wasTransformed: true }; } else { // Insert is within the deleted range - move to start of deleted range const transformed = { ...operationA, position: operationB.position, }; return { operation: transformed, wasTransformed: true }; } } /** * Transform Delete against Insert * The delete range might need adjustment based on where the insert occurs */ function transformDeleteInsert$1(operationA, operationB) { if (operationB.position <= operationA.position) { // Insert comes before delete - shift delete position const transformed = { ...operationA, position: operationA.position + operationB.text.length, }; return { operation: transformed, wasTransformed: true }; } else if (operationB.position >= operationA.position + operationA.length) { // Insert comes after delete - no change needed return { operation: operationA, wasTransformed: false }; } else { // Insert is within delete range - expand delete to include inserted text const transformed = { ...operationA, length: operationA.length + operationB.text.length, }; return { operation: transformed, wasTransformed: true }; } } /** * Transform Delete against Delete * Handle overlapping or adjacent deletes */ function transformDeleteDelete$2(operationA, operationB) { const aStart = operationA.position; const aEnd = operationA.position + operationA.length; const bStart = operationB.position; const bEnd = operationB.position + operationB.length; // No overlap - B comes after A if (bStart >= aEnd) { return { operation: operationA, wasTransformed: false }; } // No overlap - B comes before A if (bEnd <= aStart) { const transformed = { ...operationA, position: operationA.position - operationB.length, }; return { operation: transformed, wasTransformed: true }; } // Overlapping deletes - need to adjust based on overlap const overlapStart = Math.max(aStart, bStart); const overlapEnd = Math.min(aEnd, bEnd); const overlapLength = overlapEnd - overlapStart; if (overlapLength > 0) { // There's an overlap - reduce A's length by the overlap const newLength = operationA.length - overlapLength; const newPosition = bStart < aStart ? aStart - (bStart < aStart ? Math.min(operationB.length, aStart - bStart) : 0) : aStart; if (newLength <= 0) { // A is completely covered by B - make it a no-op const transformed = { ...operationA, position: newPosition, length: 0, }; return { operation: transformed, wasTransformed: true }; } const transformed = { ...operationA, position: newPosition, length: newLength, }; return { operation: transformed, wasTransformed: true }; } return { operation: operationA, wasTransformed: false }; } /** * Check if two text operations conflict */ function textOperationsConflict(operationA, operationB) { // Two operations conflict if they operate on overlapping ranges const getRangeA = getOperationRange(operationA); const getRangeB = getOperationRange(operationB); // Check for range overlap return getRangeA.start < getRangeB.end && getRangeB.start < getRangeA.end; } /** * Get the range (start, end) that an operation affects */ function getOperationRange(operation) { if (isInsertOperation(operation)) { return { start: operation.position, end: operation.position }; } else if (isDeleteOperation(operation)) { return { start: operation.position, end: operation.position + operation.length }; } else if (isRetainOperation(operation)) { return { start: operation.position, end: operation.position + operation.length }; } return { start: 0, end: 0 }; } /** * Compose multiple text operations into a single operation * This is useful for optimizing operation sequences */ function composeTextOperations(operations) { if (operations.length === 0) return []; if (operations.length === 1) return operations; const result = []; let current = operations[0]; for (let i = 1; i < operations.length; i++) { const next = operations[i]; // Try to merge consecutive operations if (canMergeOperations(current, next)) { current = mergeOperations(current, next); } else { result.push(current); current = next; } } result.push(current); return result; } /** * Check if two operations can be merged */ function canMergeOperations(op1, op2) { // Only merge operations from the same client if (op1.clientId !== op2.clientId) return false; // Can merge consecutive inserts if (isInsertOperation(op1) && isInsertOperation(op2)) { return op1.position + op1.text.length === op2.position; } // Can merge consecutive deletes if (isDeleteOperation(op1) && isDeleteOperation(op2)) { return op1.position === op2.position; } return false; } /** * Merge two compatible operations */ function mergeOperations(op1, op2) { if (isInsertOperation(op1) && isInsertOperation(op2)) { return { ...op1, text: op1.text + op2.text, }; } if (isDeleteOperation(op1) && isDeleteOperation(op2)) { return { ...op1, length: op1.length + op2.length, }; } return op1; } /** * Simple event emitter implementation for shared types and clients */ /** * Generic event emitter class */ class EventEmitter { constructor() { this.listeners = new Map(); } /** * Add an event listener */ on(event, listener) { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event).add(listener); } /** * Remove an event listener */ off(event, listener) { const eventListeners = this.listeners.get(event); if (eventListeners) { eventListeners.delete(listener); if (eventListeners.size === 0) { this.listeners.delete(event); } } } /** * Add a one-time event listener */ once(event, listener) { const onceListener = ((...args) => { this.off(event, onceListener); listener(...args); }); this.on(event, onceListener); } /** * Emit an event to all listeners */ emit(event, ...args) { const eventListeners = this.listeners.get(event); if (eventListeners) { // Create a copy of the listeners set to avoid issues if listeners are modified during emission const listenersArray = Array.from(eventListeners); for (const listener of listenersArray) { try { listener(...args); } catch (error) { // Log error but don't break other listeners console.error('Error in event listener:', error); } } } } /** * Remove all listeners for a specific event, or all listeners if no event specified */ removeAllListeners(event) { if (event) { this.listeners.delete(event); } else { this.listeners.clear(); } } /** * Get the number of listeners for an event */ listenerCount(event) { const eventListeners = this.listeners.get(event); return eventListeners ? eventListeners.size : 0; } /** * Get all event names that have listeners */ eventNames() { return Array.from(this.listeners.keys()); } /** * Check if there are any listeners for an event */ hasListeners(event) { return this.listenerCount(event) > 0; } } /** * Utility functions for generating unique identifiers */ /** * Generate a unique identifier for operations, documents, or clients */ function generateId() { // Use timestamp + random for uniqueness const timestamp = Date.now().toString(36); const random = Math.random().toString(36).substring(2); return `${timestamp}-${random}`; } /** * Generate a client-specific operation ID */ function generateOperationId(clientId) { return `${clientId}-${generateId()}`; } /** * SharedText implementation for collaborative text editing */ /** * Collaborative text data type with operational transformation */ class SharedText extends EventEmitter { constructor(initialValue = '', clientId, initialVersion = 0) { super(); this._value = initialValue; this._version = initialVersion; this._clientId = clientId; } /** * Current text value */ get value() { return this._value; } /** * Current version */ get version() { return this._version; } /** * Insert text at a specific position */ insert(position, text) { if (position < 0 || position > this._value.length) { throw new Error(`Invalid position: ${position}. Text length is ${this._value.length}`); } if (text.length === 0) { throw new Error('Cannot insert empty text'); } const operation = createInsertOperation(generateOperationId(this._clientId), this._clientId, this._version, position, text); this.apply(operation); return operation; } /** * Delete text at a specific position */ delete(position, length = 1) { if (position < 0 || position >= this._value.length) { throw new Error(`Invalid position: ${position}. Text length is ${this._value.length}`); } if (length <= 0) { throw new Error('Delete length must be positive'); } // Adjust length if it exceeds available text const actualLength = Math.min(length, this._value.length - position); const operation = createDeleteOperation(generateOperationId(this._clientId), this._clientId, this._version, position, actualLength); this.apply(operation); return operation; } /** * Replace text in a range */ replace(position, length, newText) { const operations = []; // First delete the existing text if (length > 0) { operations.push(this.delete(position, length)); } // Then insert the new text if (newText.length > 0) { operations.push(this.insert(position, newText)); } return operations; } /** * Set the entire text value (generates operations for the change) */ setText(newText) { return this.generateOperations(this._value, newText); } /** * Apply an operation to the text */ apply(operation) { const oldValue = this._value; if (isInsertOperation(operation)) { // Insert text at position const before = this._value.substring(0, operation.position); const after = this._value.substring(operation.position); this._value = before + operation.text + after; this.emit('insert', operation.position, operation.text); } else if (isDeleteOperation(operation)) { // Delete text at position const before = this._value.substring(0, operation.position); const after = this._value.substring(operation.position + operation.length); this._value = before + after; this.emit('delete', operation.position, operation.length); } // Update version if this is a newer operation if (operation.baseVersion >= this._version) { this._version = operation.baseVersion + 1; } // Emit change event this.emit('change', this._value, oldValue); this.emit('operation', operation); return this._value; } /** * Transform this operation against another operation */ transform(operationA, operationB) { const result = transformTextOperation(operationA, operationB); if (result.wasTransformed) { this.emit('transform', operationA, result.operation); } return result; } /** * Check if two operations conflict */ conflicts(operationA, operationB) { return textOperationsConflict(operationA, operationB); } /** * Generate operations to transform from one text value to another */ generateOperations(oldValue, newValue) { const operations = []; // Simple diff algorithm - can be improved with more sophisticated algorithms const commonPrefix = this.findCommonPrefix(oldValue, newValue); const commonSuffix = this.findCommonSuffix(oldValue.substring(commonPrefix), newValue.substring(commonPrefix)); const oldMiddle = oldValue.substring(commonPrefix, oldValue.length - commonSuffix); const newMiddle = newValue.substring(commonPrefix, newValue.length - commonSuffix); // Delete old middle section if (oldMiddle.length > 0) { operations.push(createDeleteOperation(generateOperationId(this._clientId), this._clientId, this._version, commonPrefix, oldMiddle.length)); } // Insert new middle section if (newMiddle.length > 0) { operations.push(createInsertOperation(generateOperationId(this._clientId), this._clientId, this._version, commonPrefix, newMiddle)); } return operations; } /** * Get a substring of the text */ substring(start, end) { return this._value.substring(start, end); } /** * Get the length of the text */ get length() { return this._value.length; } /** * Check if the text is empty */ isEmpty() { return this._value.length === 0; } /** * Clear all text */ clear() { if (this.isEmpty()) { return null; } return this.delete(0, this._value.length); } /** * Find the longest common prefix between two strings */ findCommonPrefix(a, b) { let i = 0; const maxLength = Math.min(a.length, b.length); while (i < maxLength && a[i] === b[i]) { i++; } return i; } /** * Find the longest common suffix between two strings */ findCommonSuffix(a, b) { let i = 0; const maxLength = Math.min(a.length, b.length); while (i < maxLength && a[a.length - 1 - i] === b[b.length - 1 - i]) { i++; } return i; } /** * Create a snapshot of the current state */ toSnapshot() { return { value: this._value, version: this._version, }; } /** * Restore from a snapshot */ fromSnapshot(snapshot) { const oldValue = this._value; this._value = snapshot.value; this._version = snapshot.version; this.emit('change', this._value, oldValue); } /** * Convert to string representation */ toString() { return this._value; } /** * Create a copy of this SharedText */ clone() { const cloned = new SharedText(this._value, this._clientId, this._version); return cloned; } } /** * List-specific operations for collaborative list editing */ /** * Create an insert list operation */ function createListInsertOperation(id, clientId, baseVersion, index, item) { return { id, clientId, baseVersion, type: 'list-insert', index, item, timestamp: Date.now(), }; } /** * Create a delete list operation */ function createListDeleteOperation(id, clientId, baseVersion, index, count = 1) { return { id, clientId, baseVersion, type: 'list-delete', index, count, timestamp: Date.now(), }; } /** * Create a replace list operation */ function createListReplaceOperation(id, clientId, baseVersion, index, item, oldItem) { return { id, clientId, baseVersion, type: 'list-replace', index, item, oldItem, timestamp: Date.now(), }; } /** * Create a move list operation */ function createListMoveOperation(id, clientId, baseVersion, index, targetIndex) { return { id, clientId, baseVersion, type: 'list-move', index, targetIndex, timestamp: Date.now(), }; } /** * Helper function to check if an operation is a list operation */ function isListOperation(operation) { return operation.type.startsWith('list-'); } /** * Helper function to check if an operation is a list insert operation */ function isListInsertOperation(operation) { return operation.type === 'list-insert'; } /** * Helper function to check if an operation is a list delete operation */ function isListDeleteOperation(operation) { return operation.type === 'list-delete'; } /** * Helper function to check if an operation is a list replace operation */ function isListReplaceOperation(operation) { return operation.type === 'list-replace'; } /** * Helper function to check if an operation is a list move operation */ function isListMoveOperation(operation) { return operation.type === 'list-move'; } /** * Operational Transformation algorithms for list operations */ /** * Transform operation A against operation B for list operations */ function transformListOperation(operationA, operationB) { // Insert vs Insert if (isListInsertOperation(operationA) && isListInsertOperation(operationB)) { return transformInsertInsert(operationA, operationB); } // Insert vs Delete if (isListInsertOperation(operationA) && isListDeleteOperation(operationB)) { return transformInsertDelete(operationA, operationB); } // Delete vs Insert if (isListDeleteOperation(operationA) && isListInsertOperation(operationB)) { return transformDeleteInsert(operationA, operationB); } // Delete vs Delete if (isListDeleteOperation(operationA) && isListDeleteOperation(operationB)) { return transformDeleteDelete$1(operationA, operationB); } // Replace operations if (isListReplaceOperation(operationA)) { return transformReplace(operationA, operationB); } if (isListReplaceOperation(operationB)) { return transformAgainstReplace(operationA); } // Move operations if (isListMoveOperation(operationA)) { return transformMove(operationA, operationB); } if (isListMoveOperation(operationB)) { return transformAgainstMove(operationA, operationB); } // Default case - no transformation needed return { operation: operationA, wasTransformed: false }; } /** * Transform Insert against Insert */ function transformInsertInsert(operationA, operationB) { if (operationA.index <= operationB.index) { // A comes before or at same position as B return { operation: operationA, wasTransformed: false }; } else { // A comes after B, so A's index shifts by 1 const transformed = { ...operationA, index: operationA.index + 1, }; return { operation: transformed, wasTransformed: true }; } } /** * Transform Insert against Delete */ function transformInsertDelete(operationA, operationB) { const deleteCount = operationB.count || 1; if (operationA.index <= operationB.index) { // Insert comes before delete - no change needed return { operation: operationA, wasTransformed: false }; } else if (operationA.index >= operationB.index + deleteCount) { // Insert comes after the deleted range - adjust index const transformed = { ...operationA, index: operationA.index - deleteCount, }; return { operation: transformed, wasTransformed: true }; } else { // Insert is within the deleted range - move to start of deleted range const transformed = { ...operationA, index: operationB.index, }; return { operation: transformed, wasTransformed: true }; } } /** * Transform Delete against Insert */ function transformDeleteInsert(operationA, operationB) { const deleteCount = operationA.count || 1; if (operationB.index <= operationA.index) { // Insert comes before delete - shift delete index const transformed = { ...operationA, index: operationA.index + 1, }; return { operation: transformed, wasTransformed: true }; } else if (operationB.index >= operationA.index + deleteCount) { // Insert comes after delete - no change needed return { operation: operationA, wasTransformed: false }; } else { // Insert is within delete range - no change needed (delete will remove the inserted item too) return { operation: operationA, wasTransformed: false }; } } /** * Transform Delete against Delete */ function transformDeleteDelete$1(operationA, operationB) { const aCount = operationA.count || 1; const bCount = operationB.count || 1; const aStart = operationA.index; const aEnd = operationA.index + aCount; const bStart = operationB.index; const bEnd = operationB.index + bCount; // No overlap - B comes after A if (bStart >= aEnd) { return { operation: operationA, wasTransformed: false }; } // No overlap - B comes before A if (bEnd <= aStart) { const transformed = { ...operationA, index: operationA.index - bCount, }; return { operation: transformed, wasTransformed: true }; } // Overlapping deletes const overlapStart = Math.max(aStart, bStart); const overlapEnd = Math.min(aEnd, bEnd); const overlapCount = overlapEnd - overlapStart; if (overlapCount > 0) { const newCount = aCount - overlapCount; const newIndex = bStart < aStart ? aStart - Math.min(bCount, aStart - bStart) : aStart; if (newCount <= 0) { // A is completely covered by B - make it a no-op const transformed = { ...operationA, index: newIndex, count: 0, }; return { operation: transformed, wasTransformed: true }; } const transformed = { ...operationA, index: newIndex, count: newCount, }; return { operation: transformed, wasTransformed: true }; } return { operation: operationA, wasTransformed: false }; } /** * Transform Replace operation against another operation */ function transformReplace(operationA, operationB) { if (isListInsertOperation(operationB)) { if (operationB.index <= operationA.index) { const transformed = { ...operationA, index: operationA.index + 1, }; return { operation: transformed, wasTransformed: true }; } } else if (isListDeleteOperation(operationB)) { const deleteCount = operationB.count || 1; if (operationA.index >= operationB.index && operationA.index < operationB.index + deleteCount) { // The item being replaced is deleted - operation becomes no-op // We could either drop it or convert to insert return { operation: operationA, wasTransformed: false }; } else if (operationA.index >= operationB.index + deleteCount) { const transformed = { ...operationA, index: operationA.index - deleteCount, }; return { operation: transformed, wasTransformed: true }; } } else if (isListReplaceOperation(operationB) && operationB.index === operationA.index) { // Concurrent replace at same index - use timestamp or client ID for tie-breaking if (operationA.timestamp > operationB.timestamp || (operationA.timestamp === operationB.timestamp && operationA.clientId > operationB.clientId)) { return { operation: operationA, wasTransformed: false }; } else { // This operation loses - becomes no-op or should be dropped return { operation: operationA, wasTransformed: false }; } } return { operation: operationA, wasTransformed: false }; } /** * Transform operation against Replace */ function transformAgainstReplace(operationA, operationB) { // Replace doesn't affect indices of other operations return { operation: operationA, wasTransformed: false }; } /** * Transform Move operation against another operation */ function transformMove(operationA, operationB) { let sourceIndex = operationA.index; let targetIndex = operationA.targetIndex; let wasTransformed = false; if (isListInsertOperation(operationB)) { if (operationB.index <= sourceIndex) { sourceIndex++; wasTransformed = true; } if (operationB.index <= targetIndex) { targetIndex++; wasTransformed = true; } } else if (isListDeleteOperation(operationB)) { const deleteCount = operationB.count || 1; if (sourceIndex >= operationB.index && sourceIndex < operationB.index + deleteCount) { // Source item is deleted - operation becomes invalid return { operation: operationA, wasTransformed: false }; } else if (sourceIndex >= operationB.index + deleteCount) { sourceIndex -= deleteCount; wasTransformed = true; } if (targetIndex >= operationB.index + deleteCount) { targetIndex -= deleteCount; wasTransformed = true; } else if (targetIndex >= operationB.index) { targetIndex = operationB.index; wasTransformed = true; } } if (wasTransformed) { const transformed = { ...operationA, index: sourceIndex, targetIndex: targetIndex, }; return { operation: transformed, wasTransformed: true }; } return { operation: operationA, wasTransformed: false }; } /** * Transform operation against Move */ function transformAgainstMove(operationA, operationB) { // Move operations can affect indices depending on the direction and range const sourceIndex = operationB.index; const targetIndex = operationB.targetIndex; if (sourceIndex === targetIndex) { // No-op move return { operation: operationA, wasTransformed: false }; } let transformed = { ...operationA }; let wasTransformed = false; if ('index' in transformed) { const opIndex = transformed.index; if (sourceIndex < targetIndex) { // Moving forward if (opIndex === sourceIndex) { transformed.index = targetIndex; wasTransformed = true; } else if (opIndex > sourceIndex && opIndex <= targetIndex) { transformed.index = opIndex - 1; wasTransformed = true; } } else { // Moving backward if (opIndex === sourceIndex) { transformed.index = targetIndex; wasTransformed = true; } else if (opIndex >= targetIndex && opIndex < sourceIndex) { transformed.index = opIndex + 1; wasTransformed = true; } } } return { operation: transformed, wasTransformed }; } /** * Check if two list operations conflict */ function listOperationsConflict(operationA, operationB) { // Operations conflict if they operate on the same index if ('index' in operationA && 'index' in operationB) { return operationA.index === operationB.index; } return false; } /** * SharedList implementation for collaborative list editing */ /** * Collaborative list data type with operational transformation */ class SharedList extends EventEmitter { constructor(initialItems = [], clientId, initialVersion = 0) { super(); this._items = [...initialItems]; this._version = initialVersion; this._clientId = clientId; } /** * Current list value */ get value() { return [...this._items]; } /** * Current version */ get version() { return this._version; } /** * Number of items in the list */ get length() { return this._items.length; } /** * Insert an item at a specific index */ insert(index, item) { if (index < 0 || index > this._items.length) { throw new Error(`Invalid index: ${index}. List length is ${this._items.length}`); } const operation = createListInsertOperation(generateOperationId(this._clientId), this._clientId, this._version, index, item); this.apply(operation); return operation; } /** * Add an item to the end of the list */ push(item) { return this.insert(this._items.length, item); } /** * Add an item to the beginning of the list */ unshift(item) { return this.insert(0, item); } /** * Delete an item at a specific index */ delete(index, count = 1) { if (index < 0 || index >= this._items.length) { throw new Error(`Invalid index: ${index}. List length is ${this._items.length}`); } if (count <= 0) { throw new Error('Delete count must be positive'); } // Adjust count if it exceeds available items const actualCount = Math.min(count, this._items.length - index); const operation = createListDeleteOperation(generateOperationId(this._clientId), this._clientId, this._version, index, actualCount); this.apply(operation); return operation; } /** * Remove the last item from the list */ pop() { if (this._items.length === 0) { return null; } return this.delete(this._items.length - 1); } /** * Remove the first item from the list */ shift() { if (this._items.length === 0) { return null; } return this.delete(0); } /** * Replace an item at a specific index */ replace(index, newItem) { if (index < 0 || index >= this._items.length) { throw new Error(`Invalid index: ${index}. List length is ${this._items.length}`); } const oldItem = this._items[index]; const operation = createListReplaceOperation(generateOperationId(this._clientId), this._clientId, this._version, index, newItem, oldItem); this.apply(operation); return operation; } /** * Set an item at a specific index (alias for replace) */ set(index, item) { return this.replace(index, item); } /** * Move an item from one index to another */ move(fromIndex, toIndex) { if (fromIndex < 0 || fromIndex >= this._items.length) { throw new Error(`Invalid fromIndex: ${fromIndex}. List length is ${this._items.length}`); } if (toIndex < 0 || toIndex >= this._items.length) { throw new Error(`Invalid toIndex: ${toIndex}. List length is ${this._items.length}`); } if (fromIndex === toIndex) { throw new Error('fromIndex and toIndex cannot be the same'); } const operation = createListMoveOperation(generateOperationId(this._clientId), this._clientId, this._version, fromIndex, toIndex); this.apply(operation); return operation; } /** * Get an item at a specific index */ get(index) { if (index < 0 || index >= this._items.length) { return undefined; } return this._items[index]; } /** * Check if the list contains an item */ includes(item) { return this._items.includes(item); } /** * Find the index of an item */ indexOf(item) { return this._items.indexOf(item); } /** * Clear all items from the list */ clear() { if (this._items.length === 0) { return null; } return this.delete(0, this._items.length); } /** * Apply an operation to the list */ apply(operation) { const oldValue = [...this._items]; if (isListInsertOperation(operation)) { this._items.splice(operation.index, 0, operation.item); this.emit('insert', operation.index, operation.item); } else if (isListDeleteOperation(operation)) { const deletedItems = this._items.splice(operation.index, operation.count || 1); for (let i = 0; i < deletedItems.length; i++) { this.emit('delete', operation.index + i, deletedItems[i]); } } else if (isListReplaceOperation(operation)) { const oldItem = this._items[operation.index]; this._items[operation.index] = operation.item; this.emit('replace', operation.index, operation.item, oldItem); } else if (isListMoveOperation(operation)) { const [movedItem] = this._items.splice(operation.index, 1); this._items.splice(operation.targetIndex, 0, movedItem); this.emit('move', operation.index, operation.targetIndex, movedItem); } // Update version if this is a newer operation if (operation.baseVersion >= this._version) { this._version = operation.baseVersion + 1; } // Emit change event this.emit('change', this.value, oldValue); this.emit('operation', operation); return this.value; } /** * Transform this operation against another operation */ transform(operationA, operationB) { const result = transformListOperation(operationA, operationB); if (result.wasTransformed) { this.emit('transform', operationA, result.operation); } return result; } /** * Check if two operations conflict */ conflicts(operationA, operationB) { return listOperationsConflict(operationA, operationB); } /** * Generate operations to transform from one list to another */ generateOperations(oldValue, newValue) { const operations = []; // Simple approach: find differences and generate operations // This can be optimized with more sophisticated diff algorithms const maxLength = Math.max(oldValue.length, newValue.length); for (let i = 0; i < maxLength; i++) { const oldItem = i < oldValue.length ? oldValue[i] : undefined; const newItem = i < newValue.length ? newValue[i] : undefined; if (oldItem === undefined && newItem !== undefined) { // Insert new item operations.push(createListInsertOperation(generateOperationId(this._clientId), this._clientId, this._version, i, newItem)); } else if (oldItem !== undefined && newItem === undefined) { // Delete old item operations.push(createListDeleteOperation(generateOperationId(this._clientId), this._clientId, this._version, i, 1)); } else if (oldItem !== newItem && oldItem !== undefined && newItem !== undefined) { // Replace item operations.push(createListReplaceOperation(generateOperationId(this._clientId), this._clientId, this._version, i, newItem, oldItem)); } } return operations; } /** * Convert list to array */ toArray() { return [...this._items]; } /** * Check if the list is empty */ isEmpty() { return this._items.length === 0; } /** * Get a slice of the list */ slice(start, end) { return this._items.slice(start, end); } /** * Create a snapshot of the current state */ toSnapshot() { return { value: [...this._items], version: this._version, }; } /** * Restore from a snapshot */ fromSnapshot(snapshot) { const oldValue = this.value; this._items = [...snapshot.value]; this._version = snapshot.version; this.emit('change', this.value, oldValue); } /** * Create a copy of this SharedList */ clone() { return new SharedList(this._items, this._clientId, this._version); } /** * Iterate over the list */ [Symbol.iterator]() { return this._items[Symbol.iterator](); } /** * For each item in the list */ forEach(callback) { this._items.forEach(callback); } /** * Map over the list */ map(callback) { return this._items.map(callback); } /** * Filter the list */ filter(callback) { return this._items.filter(callback); } /** * Find an item in the list */ find(callback) { return this._items.find(callback); } /** * Find the index of an item in the list */ findIndex(callback) { return this._items.findIndex(callback); } } /** * Map/Object-specific operations for collaborative object editing */ /** * Create a set map operation */ function createMapSetOperation(id, clientId, baseVersion, key, value, previousValue) { return { id, clientId, baseVersion, type: 'map-set', key, value, previousValue, timestamp: Date.now(), }; } /** * Create a delete map operation */ function createMapDeleteOperation(id, clientId, baseVersion, key, previousValue) { return { id, clientId, baseVersion, type: 'map-delete', key, previousValue, timestamp: Date.now(), }; } /** * Create a batch map operation */ function createMapBatchOperation(id, clientId, baseVersion, operations) { return { id, clientId, baseVersion, type: 'map-batch', operations, timestamp: Date.now(), }; } /** * Helper function to check if an operation is a map operation */ function isMapOperation(operation) { return operation.type.startsWith('map-'); } /** * Helper function to check if an operation is a map set operation */ function isMapSetOperation(operation) { return operation.type === 'map-set'; } /** * Helper function to check if an operation is a map delete operation */ function isMapDeleteOperation(operation) { return operation.type === 'map-delete'; } /** * Helper function to check if an operation is a map batch operation */ function isMapBatchOperation(operation) { return operation.type === 'map-batch'; } /** * Operational Transformation algorithms for map operations */ /** * Transform operation A against operation B for map operations */ function transformMapOperation(operationA, operationB) { // Handle batch operations if (isMapBatchOperation(operationA)) { return transformBatchOperation(operationA, operationB); } if (isMapBatchOperation(operationB)) { return transformAgainstBatch(operationA, operationB); } // Set vs Set if (isMapSetOperation(operationA) && isMapSetOperation(operationB)) { return transformSetSet(operationA, operationB); } // Set vs Delete if (isMapSetOperation(operationA) && isMapDeleteOperation(operationB)) { return transformSetDelete(operationA, operationB); } // Delete vs Set if (isMapDeleteOperation(operationA) && isMapSetOperation(operationB)) { return transformDeleteSet(operationA, operationB); } // Delete vs Delete if (isMapDeleteOperation(operationA) && isMapDeleteOperation(operationB)) { return transformDeleteDelete(operationA, operationB); } // Default case - no transformation needed return { operation: operationA, wasTransformed: false }; } /** * Transform Set against Set * When two sets happen on the same key, we need conflict resolution */ function transformSetSet(operationA, operationB) { if (operationA.key !== operationB.key) { // Different keys - no conflict return { operation: operationA, wasTransformed: false }; } // Same key - conflict resolution needed // Use timestamp-based resolution (last write wins) // or client ID for deterministic resolution if (operationA.timestamp > operationB.timestamp || (operationA.timestamp === operationB.timestamp && operationA.clientId > operationB.clientId)) { // A wins - but we need to update previousValue to B's value const transformed = { ...operationA, previousValue: operationB.value, }; return { operation: transformed, wasTransformed: true }; } else { // B wins - A becomes a no-op or should be dropped // For now, we'll keep A but mark it as transformed return { operation: operationA, wasTransformed: true }; } } /** * Transform Set against Delete */ function transformSetDelete(operationA, operationB) { if (operationA.key !== operationB.key) { // Different keys - no conflict return { operation: operationA, wasTransformed: false }; } // Same key - the delete removes the key, so set will create it again // Update previousValue to undefined since the key was deleted const transformed = { ...operationA, previousValue: undefined, }; return { operation: transformed, wasTransformed: true }; } /** * Transform Delete against Set */ function transformDeleteSet(operationA, operationB) { if (operationA.key !== operationB.key) { // Different keys - no conflict return { operation: operationA, wasTransformed: false }; } // Same key - the set operation changes the value, so delete should use the new value const transformed = { ...operationA, previousValue: operationB.value, }; return { operation: transformed, wasTransformed: true }; } /** * Transform Delete against Delete */ function transformDeleteDelete(operationA, operationB) { if (operationA.key !== operationB.key) { // Different keys - no conflict return { operation: operationA, wasTransformed: false }; } // Same key - both trying to delete the same key // The first one wins, second becomes no-op if (operationA.timestamp > operationB.timestamp || (operationA.timestamp === operationB.timestamp && operationA.clientId > operationB.clientId)) { // A wins return { operation: operationA, wasTransformed: false }; } else { // B wins - A becomes no-op return { operation: operationA, wasTransformed: true }; } } /** * Transform a batch operation against another operation */ function transformBatchOperation(operationA, operationB) { let wasTransformed = false; const transformedOperations = []; for (const subOp of operationA.operations) { const result = transformMapOperation(subOp, operationB); transformedOperations.push(result.operation); if (result.wasTransformed) { wasTransformed = true; } } if (wasTransformed) { const transformed = { ...operationA, operations: transformedOperations, }; return { operation: transformed, wasTransformed: true }; } return { operation: operationA, wasTransformed: false }; } /** * Transform an operation against a batch operation */ function transformAgainstBatch(operationA, operationB) { let current = operationA; let wasTransformed = false; for (const subOp of operationB.operations) { const result = transformMapOperation(current, subOp); current = result.operation; if (result.wasTransformed) { wasTransformed = true; } } return { operation: current, wasTransformed }; } /** * Check if two map operations conflict */ function mapOperationsConflict(operationA, operationB) { // Extract keys from operations const getKeys = (op) => { if (isMapBatchOperation(op)) { return op.operations.map(subOp => subOp.key); } else if ('key' in op) { return [op.key]; } return []; }; const keysA = getKeys(operationA); const keysB = getKeys(operationB); // Check if any keys overlap for (const keyA of keysA) { if (keysB.includes(keyA)) { return true; } } return false; } /** * Optimize map operations by merging compatible operations */ function optimizeMapOperations(operations) { if (operations.length === 0) return [];