@thesaasdevkit/rtc-core
Version:
Core operational transform algorithms and shared data types for RTCC
272 lines • 9.02 kB
JavaScript
/**
* Operational Transformation algorithms for map operations
*/
import { isMapSetOperation, isMapDeleteOperation, isMapBatchOperation, } from '../types/map-operations.js';
/**
* Transform operation A against operation B for map operations
*/
export 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
*/
export 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
*/
export function optimizeMapOperations(operations) {
if (operations.length === 0)
return [];
// Group operations by key
const keyGroups = new Map();
const batchOps = [];
for (const op of operations) {
if (isMapBatchOperation(op)) {
batchOps.push(op);
}
else if ('key' in op) {
const key = op.key;
if (!keyGroups.has(key)) {
keyGroups.set(key, []);
}
keyGroups.get(key).push(op);
}
}
const result = [];
// Process each key group
for (const [key, ops] of keyGroups) {
if (ops.length === 1) {
result.push(ops[0]);
}
else {
// Find the last operation for this key (latest timestamp)
const lastOp = ops.reduce((latest, current) => current.timestamp > latest.timestamp ? current : latest);
result.push(lastOp);
}
}
// Add batch operations (they can't be optimized easily)
result.push(...batchOps);
return result;
}
/**
* Apply a map operation to a state object
*/
export function applyMapOperation(state, operation) {
const newState = { ...state };
if (isMapSetOperation(operation)) {
newState[operation.key] = operation.value;
}
else if (isMapDeleteOperation(operation)) {
delete newState[operation.key];
}
else if (isMapBatchOperation(operation)) {
for (const subOp of operation.operations) {
if (isMapSetOperation(subOp)) {
newState[subOp.key] = subOp.value;
}
else if (isMapDeleteOperation(subOp)) {
delete newState[subOp.key];
}
}
}
return newState;
}
/**
* Generate operations to transform one map state to another
*/
export function generateMapOperations(oldState, newState, clientId, baseVersion) {
const operations = [];
const operationId = () => `${clientId}-${Date.now()}-${Math.random()}`;
// Find added/changed keys
for (const [key, newValue] of Object.entries(newState)) {
const oldValue = oldState[key];
if (oldValue !== newValue) {
operations.push({
id: operationId(),
clientId,
baseVersion,
type: 'map-set',
key,
value: newValue,
previousValue: oldValue,
timestamp: Date.now(),
});
}
}
// Find deleted keys
for (const [key, oldValue] of Object.entries(oldState)) {
if (!(key in newState)) {
operations.push({
id: operationId(),
clientId,
baseVersion,
type: 'map-delete',
key,
previousValue: oldValue,
timestamp: Date.now(),
});
}
}
return operations;
}
//# sourceMappingURL=map-transform.js.map