@thesaasdevkit/rtc-core
Version:
Core operational transform algorithms and shared data types for RTCC
298 lines • 10.9 kB
JavaScript
/**
* Operational Transformation algorithms for list operations
*/
import { isListInsertOperation, isListDeleteOperation, isListReplaceOperation, isListMoveOperation, } from '../types/list-operations.js';
/**
* Transform operation A against operation B for list operations
*/
export 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(operationA, operationB);
}
// Replace operations
if (isListReplaceOperation(operationA)) {
return transformReplace(operationA, operationB);
}
if (isListReplaceOperation(operationB)) {
return transformAgainstReplace(operationA, operationB);
}
// 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(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
*/
export 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;
}
//# sourceMappingURL=list-transform.js.map