Merge pull request #34714 from overleaf/mj-tracked-change-merges

[overleaf-editor-core+ranges-tracker] Pick lower timestamp when merging tracking info

GitOrigin-RevId: 5b1c8d7c719003df4506014c381f2a525d0a7ff4
This commit is contained in:
Mathias Jakobsen
2026-06-19 08:07:27 +00:00
committed by Copybot
parent 7ee385fe45
commit ef05288f81
6 changed files with 257 additions and 35 deletions
@@ -56,8 +56,7 @@ class TrackedChange {
return false return false
} }
return ( return (
this.tracking.type === other.tracking.type && this.tracking.canMergeWith(other.tracking) &&
this.tracking.userId === other.tracking.userId &&
this.range.touches(other.range) && this.range.touches(other.range) &&
this.range.canMerge(other.range) this.range.canMerge(other.range)
) )
@@ -75,13 +74,7 @@ class TrackedChange {
} }
return new TrackedChange( return new TrackedChange(
this.range.merge(other.range), this.range.merge(other.range),
new TrackingProps( this.tracking.mergeWith(other.tracking)
this.tracking.type,
this.tracking.userId,
this.tracking.ts.getTime() > other.tracking.ts.getTime()
? this.tracking.ts
: other.tracking.ts
)
) )
} }
@@ -405,6 +405,49 @@ describe('TextOperation', function () {
}) })
) )
it(
'compose associativity (randomised)',
random.test(numTrials, () => {
const str = random.string(20)
const comments = random.comments(6)
const a = randomOperation(str, comments.ids)
const afterA = new StringFileData(str, comments.comments)
a.apply(afterA)
const b = randomOperation(afterA.getContent(), comments.ids)
const afterB = new StringFileData(
afterA.getContent(),
comments.comments
)
b.apply(afterB)
const c = randomOperation(afterB.getContent(), comments.ids)
const ab = a.compose(b)
const ab_c = ab.compose(c)
const bc = b.compose(c)
const a_bc = a.compose(bc)
const ab_c_file = new StringFileData(str, comments.comments)
ab_c.apply(ab_c_file)
const a_bc_file = new StringFileData(str, comments.comments)
a_bc.apply(a_bc_file)
const fuzzingError = fuzzingErrorMessage({
str,
comments,
a: a.toJSON(),
b: b.toJSON(),
c: c.toJSON(),
})
expect(ab_c_file.toRaw()).to.deep.equal(a_bc_file.toRaw(), fuzzingError)
})
)
it('composes two operations with comments', function () { it('composes two operations with comments', function () {
expect( expect(
compose( compose(
@@ -625,11 +668,54 @@ describe('TextOperation', function () {
a: a.toJSON(), a: a.toJSON(),
b: b.toJSON(), b: b.toJSON(),
}) })
expect(abPrime.equals(baPrime)).to.be.equal(true, fuzzingError) // The composition of ab' and ba' is not guaranteed to be equal, but
// should converge to the same file contents + ranges.
expect(abFile.toRaw()).to.deep.equal(baFile.toRaw(), fuzzingError) expect(abFile.toRaw()).to.deep.equal(baFile.toRaw(), fuzzingError)
}) })
) )
it('chooses lower tracked change timestamp', function () {
const ts1 = '2024-01-01T01:00:00.000Z'
const ts2 = '2024-01-01T02:00:00.000Z'
const str = 'abcde'
const comments = []
const a = new TextOperation()
.retain(2, {
tracking: TrackingProps.fromRaw({
ts: ts1,
type: 'insert',
userId: 'user1',
}),
})
.retain(1)
.retain(2, {
tracking: TrackingProps.fromRaw({
ts: ts2,
type: 'insert',
userId: 'user1',
}),
})
const b = new TextOperation().retain(1).remove(3).retain(1)
const [aPrime, bPrime] = TextOperation.transform(a, b)
const aComposeBPrime = a.compose(bPrime)
const bComposeAPrime = b.compose(aPrime)
const aBPFile = new StringFileData(str, comments)
aComposeBPrime.apply(aBPFile)
const bAPFile = new StringFileData(str, comments)
bComposeAPrime.apply(bAPFile)
expect(aBPFile.toRaw()).to.deep.equal(bAPFile.toRaw())
expect(aBPFile.trackedChanges.length).to.equal(1)
expect(
aBPFile.trackedChanges.asSorted()[0].tracking.ts.toISOString()
).to.equal(ts1)
})
it('adds a tracked change from operation 1', function () { it('adds a tracked change from operation 1', function () {
expect( expect(
transform( transform(
@@ -8,7 +8,7 @@ const { expect } = require('chai')
describe('TrackedChangeList', function () { describe('TrackedChangeList', function () {
describe('applyInsert', function () { describe('applyInsert', function () {
describe('with same author', function () { describe('with same author', function () {
it('should merge consecutive tracked changes and use the latest timestamp', function () { it('should merge consecutive tracked changes and use the earliest timestamp', function () {
const trackedChanges = TrackedChangeList.fromRaw([ const trackedChanges = TrackedChangeList.fromRaw([
{ {
range: { pos: 0, length: 3 }, range: { pos: 0, length: 3 },
@@ -33,7 +33,7 @@ describe('TrackedChangeList', function () {
tracking: { tracking: {
type: 'insert', type: 'insert',
userId: 'user1', userId: 'user1',
ts: '2024-01-01T00:00:00.000Z', ts: '2023-01-01T00:00:00.000Z',
}, },
}, },
]) ])
@@ -699,7 +699,7 @@ describe('TrackedChangeList', function () {
tracking: { tracking: {
type: 'insert', type: 'insert',
userId: 'user1', userId: 'user1',
ts: '2024-01-01T00:00:00.000Z', ts: '2023-01-01T00:00:00.000Z',
}, },
}, },
]) ])
+16 -1
View File
@@ -420,7 +420,7 @@ class RangesTracker {
offset = opStart - changeStart offset = opStart - changeStart
change.op.i = change.op.i =
change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset) change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
change.metadata.ts = metadata.ts change.metadata.ts = pickTimestamp(change.metadata, metadata)
alreadyMerged = true alreadyMerged = true
movedChanges.push(change) movedChanges.push(change)
} else if (opStart <= changeStart) { } else if (opStart <= changeStart) {
@@ -728,6 +728,10 @@ class RangesTracker {
) { ) {
removeChanges.push(change) removeChanges.push(change)
previousChange.op.i += change.op.i previousChange.op.i += change.op.i
previousChange.metadata.ts = pickTimestamp(
previousChange.metadata,
change.metadata
)
movedChanges.push(previousChange) movedChanges.push(previousChange)
} }
} else if ( } else if (
@@ -790,4 +794,15 @@ class RangesTracker {
} }
} }
function pickTimestamp(oldMetadata, newMetadata) {
// Make sure null values don't get treated as 1970-01-01 dates in the
// comparison below.
if (oldMetadata.ts == null) return newMetadata.ts
if (newMetadata.ts == null) return oldMetadata.ts
return new Date(oldMetadata.ts) < new Date(newMetadata.ts)
? oldMetadata.ts
: newMetadata.ts
}
module.exports = RangesTracker module.exports = RangesTracker
@@ -222,4 +222,114 @@ describe('RangesTracker', function () {
) )
}) })
}) })
describe('merging tracked inserts from the same user', function () {
beforeEach(function () {
this.comments = []
})
it('keeps the earlier timestamp when the existing insert is older', function () {
this.rangesTracker = new RangesTracker(
[
{
id: 'id1',
op: { p: 10, i: 'foo' },
metadata: { user_id: 'user-1', ts: '2024-01-01T00:00:00.000Z' },
},
],
this.comments
)
this.rangesTracker.track_changes = true
this.rangesTracker.applyOp(
{ p: 13, i: 'bar' },
{ user_id: 'user-1', ts: new Date('2024-06-01T00:00:00.000Z') }
)
expect(this.rangesTracker.changes).to.have.length(1)
const [change] = this.rangesTracker.changes
expect(change.op).to.deep.equal({ p: 10, i: 'foobar' })
expect(change.metadata.user_id).to.equal('user-1')
expect(new Date(change.metadata.ts).toISOString()).to.equal(
'2024-01-01T00:00:00.000Z'
)
})
it('keeps the earlier timestamp when the incoming insert is older', function () {
this.rangesTracker = new RangesTracker(
[
{
id: 'id1',
op: { p: 10, i: 'foo' },
metadata: { user_id: 'user-1', ts: '2024-06-01T00:00:00.000Z' },
},
],
this.comments
)
this.rangesTracker.track_changes = true
this.rangesTracker.applyOp(
{ p: 13, i: 'bar' },
{ user_id: 'user-1', ts: new Date('2024-01-01T00:00:00.000Z') }
)
expect(this.rangesTracker.changes).to.have.length(1)
const [change] = this.rangesTracker.changes
expect(change.op).to.deep.equal({ p: 10, i: 'foobar' })
expect(change.metadata.user_id).to.equal('user-1')
expect(new Date(change.metadata.ts).toISOString()).to.equal(
'2024-01-01T00:00:00.000Z'
)
})
it('uses the defined timestamp when the existing insert has a null timestamp', function () {
this.rangesTracker = new RangesTracker(
[
{
id: 'id1',
op: { p: 10, i: 'foo' },
metadata: { user_id: 'user-1', ts: null },
},
],
this.comments
)
this.rangesTracker.track_changes = true
this.rangesTracker.applyOp(
{ p: 13, i: 'bar' },
{ user_id: 'user-1', ts: new Date('2024-01-01T00:00:00.000Z') }
)
expect(this.rangesTracker.changes).to.have.length(1)
const [change] = this.rangesTracker.changes
expect(new Date(change.metadata.ts).toISOString()).to.equal(
'2024-01-01T00:00:00.000Z'
)
})
it('keeps the earlier timestamp when re-merging inserts after a deletion', function () {
this.rangesTracker = new RangesTracker(
[
{
id: 'id1',
op: { p: 10, i: 'aaa' },
metadata: { user_id: 'user-1', ts: '2024-03-01T00:00:00.000Z' },
},
{
id: 'id2',
op: { p: 13, i: 'bb' },
metadata: { user_id: 'user-2', ts: '2024-02-01T00:00:00.000Z' },
},
{
id: 'id3',
op: { p: 15, i: 'ccc' },
metadata: { user_id: 'user-1', ts: '2024-01-01T00:00:00.000Z' },
},
],
this.comments
)
this.rangesTracker.applyOp({ p: 13, d: 'bb' })
expect(this.rangesTracker.changes).to.have.length(1)
const [change] = this.rangesTracker.changes
expect(change.op).to.deep.equal({ p: 10, i: 'aaaccc' })
expect(change.metadata.user_id).to.equal('user-1')
expect(new Date(change.metadata.ts).toISOString()).to.equal(
'2024-01-01T00:00:00.000Z'
)
})
})
}) })
@@ -2,7 +2,13 @@ import sinon from 'sinon'
import { expect } from 'chai' import { expect } from 'chai'
import mongodb from 'mongodb-legacy' import mongodb from 'mongodb-legacy'
import tk from 'timekeeper' import tk from 'timekeeper'
import { File, Comment, TrackedChange, Range } from 'overleaf-editor-core' import {
File,
Comment,
TrackedChange,
Range,
TrackingProps,
} from 'overleaf-editor-core'
import { UnprocessableError } from 'overleaf-editor-core/lib/errors.js' import { UnprocessableError } from 'overleaf-editor-core/lib/errors.js'
import { strict as esmock } from 'esmock' import { strict as esmock } from 'esmock'
import { FileContentEmptyError } from '../../../../app/js/Errors.js' import { FileContentEmptyError } from '../../../../app/js/Errors.js'
@@ -2013,32 +2019,44 @@ describe('SyncManager', function () {
describe('syncing tracked changes', function () { describe('syncing tracked changes', function () {
beforeEach(function () { beforeEach(function () {
this.loadedSnapshotDoc.getTrackedChanges().add( this.loadedSnapshotDoc.getTrackedChanges().add(
new TrackedChange(new Range(4, 6), { new TrackedChange(
type: 'delete', new Range(4, 6),
userId: USER_ID, TrackingProps.fromRaw({
ts: new Date(TIMESTAMP), type: 'delete',
}) userId: USER_ID,
ts: new Date(TIMESTAMP).toISOString(),
})
)
) )
this.loadedSnapshotDoc.getTrackedChanges().add( this.loadedSnapshotDoc.getTrackedChanges().add(
new TrackedChange(new Range(10, 6), { new TrackedChange(
type: 'insert', new Range(10, 6),
userId: USER_ID, TrackingProps.fromRaw({
ts: new Date(TIMESTAMP), type: 'insert',
}) userId: USER_ID,
ts: new Date(TIMESTAMP).toISOString(),
})
)
) )
this.loadedSnapshotDoc.getTrackedChanges().add( this.loadedSnapshotDoc.getTrackedChanges().add(
new TrackedChange(new Range(20, 6), { new TrackedChange(
type: 'delete', new Range(20, 6),
userId: USER_ID, TrackingProps.fromRaw({
ts: new Date(TIMESTAMP), type: 'delete',
}) userId: USER_ID,
ts: new Date(TIMESTAMP).toISOString(),
})
)
) )
this.loadedSnapshotDoc.getTrackedChanges().add( this.loadedSnapshotDoc.getTrackedChanges().add(
new TrackedChange(new Range(40, 3), { new TrackedChange(
type: 'insert', new Range(40, 3),
userId: USER_ID, TrackingProps.fromRaw({
ts: new Date(TIMESTAMP), type: 'insert',
}) userId: USER_ID,
ts: new Date(TIMESTAMP).toISOString(),
})
)
) )
this.changes = [ this.changes = [
makeTrackedChange('td1', { p: 4, d: 'quick ' }), makeTrackedChange('td1', { p: 4, d: 'quick ' }),