@thesaasdevkit/rtc-core
Version:
Core operational transform algorithms and shared data types for RTCC
1,588 lines (1,577 loc) • 64 kB
JavaScript
'use strict';
/**
* 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)