Skip to content

imp(): implement all operations transformations #115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from

Conversation

e11sy
Copy link
Contributor

@e11sy e11sy commented Apr 11, 2025

Changes

  • OperationsBatch now extends Operation
  1. It has own lits of operations
  2. It can transform list of operations against one
  3. It can inverse list of operations
  • Added OperationType.Neutral - operation, that does not affect model on apply, so could be skipped, this type of operations could appear after transformation
  • Now Undo/Redo stacks and current OperationsBatch are transformed on event, got from server (with userId !== userId of the current client)
  • Supported all transformations for Operation
  • Prevent default browser behaviour on Ctrl + Z to avoid manipulations with browser folders

Copy link

github-actions bot commented Apr 11, 2025

⏭️ No files to mutate for ./packages/model

Copy link

github-actions bot commented Apr 11, 2025

⏭️ No files to mutate for ./packages/dom-adapters

Copy link

github-actions bot commented Apr 11, 2025

Coverage report for ./packages/dom-adapters

St.
Category Percentage Covered / Total
🟢 Statements 100% 0/0
🟢 Branches 100% 0/0
🟢 Functions 100% 0/0
🟢 Lines 100% 0/0

Test suite run success

1 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from 61d4dab

Copy link

github-actions bot commented Apr 11, 2025

Coverage report for ./packages/model

St.
Category Percentage Covered / Total
🟢 Statements 99.65% 852/855
🟢 Branches 98.7% 228/231
🟢 Functions 98.1% 207/211
🟢 Lines 99.64% 819/822

Test suite run success

441 tests passing in 25 suites.

Report generated by 🧪jest coverage report action from a74d393

Copy link

github-actions bot commented Apr 11, 2025

Coverage report for ./packages/ot-server

St.
Category Percentage Covered / Total
🟡 Statements 70.97% 22/31
🔴 Branches 20% 1/5
🟡 Functions 75% 6/8
🟡 Lines 68.97% 20/29

Test suite run success

4 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from a74d393

Copy link

github-actions bot commented Apr 11, 2025

Coverage report for ./packages/collaboration-manager

St.
Category Percentage Covered / Total
🟡 Statements
71.25% (+3.74% 🔼)
228/320
🟡 Branches
77.36% (-2.64% 🔻)
82/106
🟡 Functions
68.18% (+11.04% 🔼)
30/44
🟡 Lines
71.2% (+3.68% 🔼)
225/316
Show new covered files 🐣
St.
File Statements Branches Functions Lines
🟢
... / getRangesIntersectionType.ts
94.12% 86.67% 100% 94.12%
🟢 OperationsTransformer.ts 91.3% 86.21% 100% 91.3%
🟢 BatchedOperation.ts 88.89% 76.47% 100% 88.57%
Show files with reduced coverage 🔻
St.
File Statements Branches Functions Lines
🟡 CollaborationManager.ts
80% (-3.82% 🔻)
73.08% (-0.84% 🔻)
55.56% (-6.94% 🔻)
79.76% (-4.06% 🔻)

Test suite run success

79 tests passing in 5 suites.

Report generated by 🧪jest coverage report action from a74d393

@neSpecc neSpecc requested a review from Copilot April 11, 2025 23:10
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

packages/collaboration-manager/src/BatchedOperation.ts:13

  • [nitpick] The file is named 'BatchedOperation.ts' while the exported class is named 'OperationsBatch', which may lead to confusion. Consider renaming either the file or the class so that they are consistent.
export class OperationsBatch<T extends OperationType> extends Operation<T> {

Comment on lines +223 to 227
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> {
return this.#transformer.transform(this, againstOp);
}

/**
Copy link
Preview

Copilot AI Apr 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transformation logic does not check if the adjusted text range becomes negative. This can cause tests (e.g., expecting null when over-deleting) to fail. Consider adding a validation step in the transformer methods (such as in #transformAgainstTextDelete) to return null if the resulting text range is invalid.

Suggested change
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> {
return this.#transformer.transform(this, againstOp);
}
/**
public transform<K extends OperationType>(againstOp: Operation<K> | Operation<OperationType.Neutral>): Operation<T | OperationType.Neutral> | null {
const transformedOp = this.#transformer.transform(this, againstOp);
// Validate the resulting operation's index or range
if (!this.isValidOperation(transformedOp)) {
return null;
}
return transformedOp;
}
/**
* Validates the operation to ensure its index or range is valid
*
* @param operation - operation to validate
*/
private isValidOperation(operation: Operation<T | OperationType.Neutral> | null): boolean {
if (!operation) {
return false;
}
// Example validation: Ensure index is non-negative
if (operation.index.start < 0 || operation.index.end < 0) {
return false;
}
// Add additional validation logic as needed
return true;
}
/**

Copilot uses AI. Check for mistakes.

export class OperationsTransformer {
constructor() {}

public transform<T extends OperationType>(operation: Operation<T>, againstOp: Operation<OperationType>): Operation<T> | Operation<OperationType.Neutral> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

describe logic in jsdoc

*
* Operations are batched on timeout basis or if batch is terminated from the outside
*/
export class OperationsBatch<T extends OperationType> extends Operation<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename pls

*
* @param opBatch - operation batch to clone
*/
public static from<T extends OperationType>(opBatch: OperationsBatch<T>): OperationsBatch<T>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Static methods should go before instance methods

/**
* Every batch should have at least one operation
*/
const batch = new OperationsBatch(opBatchOrJSON.operations.shift()!);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This way the first operation would be copied by reference not by value

/**
* Array of operations to batch
*/
operations: (Operation<T> | Operation<OperationType.Neutral>)[] = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifier is missing

#transformAgainstTextInsert<T extends OperationType>(operation: Operation<T>, againstOp: Operation<OperationType>): Operation<T> | Operation<OperationType.Neutral> {
const newIndexBuilder = new IndexBuilder().from(operation.index);

const amountOfInsertedCharacters = againstOp.data.payload!.length;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const amountOfInsertedCharacters = againstOp.data.payload!.length;
const insertedLength = againstOp.data.payload!.length;

newIndexBuilder.addTextRange([operation.index.textRange![0], operation.index.textRange![1] + amountOfInsertedCharacters]);
}

return new Operation(operation.type, newIndexBuilder.build(), operation.data, operation.userId, operation.rev);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const newOp = Operation.from(operation);

newOp.index = newIndexBuilder.build();

return newOp;


const deletedRightSide = (againstOp.index.textRange![0] > operation.index.textRange![0])
&& (againstOp.index.textRange![0] < operation.index.textRange![1])
&& (againstOp.index.textRange![1] <= operation.index.textRange![1]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
&& (againstOp.index.textRange![1] <= operation.index.textRange![1]);
&& (againstOp.index.textRange![1] >= operation.index.textRange![1]);

* Cover case 2.1
*/
if (deletedLeftSide) {
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];
const deletedFromCurrentOpRange = againstOp.index.textRange![1] - operation.index.textRange![0];

if (deletedLeftSide) {
const deletedFromCurrentOpRange = operation.index.textRange![0] - againstOp.index.textRange![1];

newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedFromCurrentOpRange]);
newIndexBuilder.addTextRange([againstOp.index.textRange![0], operation.index.textRange![1] - deletedAmount]);

Comment on lines 49 to 68
if (againstIndex.isBlockIndex && currentIndex.blockIndex !== undefined) {
/**
* Check that againstOp affects current operation
*/
if (againstIndex.blockIndex! <= currentIndex.blockIndex) {
return this.#transformAgainstBlockOperation(operation, againstOp);
}
}

/**
* Cover 4 case
*
* Check that againstOp is a text operation and current operation is also a text operation
*/
if (againstIndex.isTextIndex && currentIndex.isTextIndex) {
/**
* Check that againstOp affects current operation (text operation on the same block and same input)
* and against op happened on the left side or has overlapping range
*/
if (currentIndex.dataKey === againstIndex.dataKey && currentIndex.blockIndex === againstIndex.blockIndex && againstIndex.textRange![0] <= currentIndex.textRange![0]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with

switch (true) {
  case againstOp.index.isBlockIndex:
    return this.#transformAgainsBlockOperation(...);
  case againstOp.index.isTextIndex:
    return this.#transformAgainsTextOperation(...);
  default:
    return Operation.from(operation);
};

And move other conditions into the respective methods

Comment on lines 126 to 132
if (operation instanceof BatchedOperation) {
operation.operations.forEach((op) => {
this.applyOperation(op);
});
} else {
this.applyOperation(operation);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to move this condition into the applyOperation method since BatchedOperation is also an operation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, it's already there. So you can remove it here

* @todo - add debounce timeout 500ms
*/
if (!this.#currentBatch.canAdd(operation)) {
this.#undoRedoManager.put(this.#currentBatch);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can call emptyBatch here to be consistent

/**
* If current operation could not be added to the batch, then terminate current batch and create a new one with current operation
*
* @todo - add debounce timeout 500ms
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great if you can implement it in the current PR as this functionality is already in the main branch

@@ -40,231 +40,6 @@ const createOperation = (


describe('Operation', () => {
describe('.transform()', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't delete it, those tests still should pass even tho the functionality in another file now

/**
* Puts current batch to the undo stack and clears the batch
*/
#emptyBatch(): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the name as it implies the current batch is being emptied. Also it doesn't show that the current batch is being added to the undo redo manager

* Cover case 2
*/
case OperationType.Delete:
if (operation.index.blockIndex !== undefined) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You've already checked that on line 90

Comment on lines 179 to 181
if (sameInput && sameBlock && againstIndex.textRange![0] > index.textRange![1]) {
return Operation.from(operation);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can extend condition here to check if it's not the same input or not the same block

case (RangeIntersectionType.Right):
const overlapLength = index.textRange![1] - againstIndex.textRange![0];

newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - overlapLength]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can put againstOp.textRange[0] as end index here

* @param operation - operation to transform against
* @param stack - stack to transform
*/
public transformStack(operation: Operation, stack: Operation[]): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make it pure function please.

Also could be private

* @param stack - stack to transform
*/
public transformStack(operation: Operation, stack: Operation[]): void {
const transformed = stack.flatMap((op) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why flatMap? Transform doesn't return null anymore

Copy link
Member

@gohabereg gohabereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of minor comments, looks good to me otherwise

*/
const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0]));

opBatchOrJSON.operations.slice(1).forEach((op) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps the linter is not set up, but would be great to have chained calls on the new lines. Here and in the other places

Suggested change
opBatchOrJSON.operations.slice(1).forEach((op) => {
opBatchOrJSON.operations.slice(1)
.forEach((op) => {

return true;
}

if (!op.index.isTextIndex || !lastOp.index.isTextIndex) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe leave a todo that we might add other index types in the future

@@ -112,7 +119,7 @@ export class CollaborationManager {
* Redo last undone operation in the local stack
*/
public redo(): void {
this.#currentBatch?.terminate();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear why in both cases (undo and redo) there's a call moveBatchToUndo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe putBatchToUndo would be better

#debounce(): void {
clearTimeout(this.#debounceTimer);

this.#debounceTimer = setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please cover untested lines and statements

Copy link
Member

@gohabereg gohabereg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed the most important file

* Cover case 2
*/
case OperationType.Delete:
if (againstOp.index.blockIndex! >= operation.index.blockIndex!) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could be just ===, you've checked > on line 104

/**
* Cover case 2.1
*/
case (RangeIntersectionType.Left):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intersectionType contains the type of current op compared to againstOp. So with RageIntersectionType.Left the current op is on the left — the against op is on the right. It should be RangeIntersectionType.Right I guess

/**
* Cover case 2.2
*/
case (RangeIntersectionType.Right):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here should be RangeIntersectionType.Left

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should add more test cases to cover all of that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants