[web] Recover offline doc backup (#35647)

GitOrigin-RevId: 999ecc534821ab6f1bdc1bc4f7757316dab1658d
This commit is contained in:
Mathias Jakobsen
2026-09-17 11:36:07 +00:00
committed by Copybot
parent 64b6b28f69
commit 17363fcc59
6 changed files with 673 additions and 47 deletions
@@ -0,0 +1,232 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
const DOC_ID = 'doc-1'
const PROJECT_ID = 'project-1'
const USER_ID = 'user-1'
const KEY = `doc.offline-backup.${USER_ID}.${PROJECT_ID}.${DOC_ID}`
type JoinResponder = (fromVersion: number, cb: (...args: any[]) => void) => void
function writeRecord(overrides: Record<string, any> = {}) {
const record = {
docId: DOC_ID,
projectId: PROJECT_ID,
version: 5,
snapshot: 'hello',
inflightOp: null,
pendingOp: [{ i: '!', p: 5 }],
trackChanges: false,
updatedAt: 1,
inflightSubmittedIds: [],
...overrides,
}
window.sessionStorage.setItem(KEY, JSON.stringify(record))
}
function makeContainer(joinResponder: JoinResponder) {
const sent: any[] = []
const socket = {
socket: { connected: true },
publicId: 'client-1',
on: sinon.stub(),
removeListener: sinon.stub(),
emit: sinon.stub().callsFake((event: string, ...args: any[]) => {
if (event === 'joinDoc') {
const cb = args[args.length - 1]
const fromVersion = typeof args[1] === 'number' ? args[1] : -1
joinResponder(fromVersion, cb)
} else if (event === 'applyOtUpdate') {
sent.push(args[1])
}
}),
}
const watchdog = new EditorWatchdogManager({ onTimeoutHandler: () => {} })
const ideEmitter = { on: sinon.stub(), off: sinon.stub(), emit: sinon.stub() }
const container = new DocumentContainer(
DOC_ID,
socket as unknown as Socket,
watchdog,
ideEmitter as unknown as IdeEventEmitter,
sinon.stub()
)
return { container, socket, sent }
}
function joinDocCalls(socket: { emit: sinon.SinonStub }) {
return socket.emit.getCalls().filter(call => call.args[0] === 'joinDoc')
}
// call.args is [event, docId, fromVersion|options, ...]; a versioned join passes
// fromVersion (a number) there, a full join passes the options object.
const isVersioned = (call: sinon.SinonSpyCall) =>
typeof call.args[2] === 'number'
describe('DocumentContainer offline recovery', function () {
let clock: sinon.SinonFakeTimers
beforeEach(function () {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user_id', USER_ID)
window.metaAttributesCache.set('ol-project_id', PROJECT_ID)
window.metaAttributesCache.set('ol-splitTestVariants', {
'intermittent-connection-improvements': 'enabled',
})
window.sessionStorage.clear()
clock = sinon.useFakeTimers()
})
afterEach(function () {
clock.restore()
window.sessionStorage.clear()
})
it('replays the buffered edits onto the current server version', function () {
// Offline we appended '!' to 'hello'; meanwhile a collaborator inserted 'X'
// at the start, taking the server from v5 to v6.
writeRecord()
const { container, sent } = makeContainer((fromVersion, cb) => {
expect(fromVersion).to.equal(5)
cb(
null,
['Xhello'],
6,
[{ op: [{ i: 'X', p: 0 }], meta: { source: 'other' } }],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
})
container.join()
// Our append is transformed past the collaborator's insert and sent.
expect(sent).to.have.length(1)
expect(sent[0].op).to.deep.equal([{ i: '!', p: 6 }])
expect(container.getSnapshot()).to.equal('Xhello!')
})
it('loads normally (no recovery) when the split test is disabled', function () {
window.metaAttributesCache.set('ol-splitTestVariants', {})
writeRecord()
const { container, socket, sent } = makeContainer((fromVersion, cb) => {
cb(
null,
['hello'],
5,
[],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
})
container.join()
const calls = joinDocCalls(socket)
expect(calls).to.have.length(1)
expect(isVersioned(calls[0])).to.equal(false)
expect(sent).to.have.length(0)
expect(container.getSnapshot()).to.equal('hello')
})
it('skips recovery for tracked-change records', function () {
writeRecord({ trackChanges: true })
const { container, socket, sent } = makeContainer((fromVersion, cb) => {
cb(
null,
['hello'],
5,
[],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
})
container.join()
const calls = joinDocCalls(socket)
expect(calls).to.have.length(1)
expect(isVersioned(calls[0])).to.equal(false)
expect(sent).to.have.length(0)
})
it('falls back to a normal load when the server cannot catch us up', function () {
writeRecord()
const { container, socket, sent } = makeContainer((fromVersion, cb) => {
if (fromVersion >= 0) {
cb(new Error('OpRangeNotAvailable')) // baseline aged out
} else {
cb(
null,
['head'],
9,
[],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
}
})
container.join()
const calls = joinDocCalls(socket)
expect(calls).to.have.length(2)
expect(isVersioned(calls[0])).to.equal(true)
expect(isVersioned(calls[1])).to.equal(false)
expect(sent).to.have.length(0)
expect(container.getSnapshot()).to.equal('head')
})
it('falls back to a normal load when the doc is no longer sharejs-text-ot', function () {
writeRecord()
const { container, socket, sent } = makeContainer((fromVersion, cb) => {
if (fromVersion >= 0) {
cb(null, [], 6, [], { changes: [], comments: [] }, 'history-ot')
} else {
cb(
null,
['head'],
6,
[],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
}
})
container.join()
const calls = joinDocCalls(socket)
expect(calls).to.have.length(2)
expect(sent).to.have.length(0)
expect(container.getSnapshot()).to.equal('head')
})
it('falls back to a normal load when the backup cannot be applied', function () {
// A pending delete that does not match the baseline text throws during seeding.
writeRecord({ pendingOp: [{ d: 'zzz', p: 0 }] })
const { container, socket, sent } = makeContainer((fromVersion, cb) => {
cb(
null,
['head'],
5,
[],
{ changes: [], comments: [] },
'sharejs-text-ot'
)
})
container.join()
// Seeding throws before the versioned join is emitted, so only the fallback
// full join runs.
const calls = joinDocCalls(socket)
expect(calls).to.have.length(1)
expect(isVersioned(calls[0])).to.equal(false)
expect(sent).to.have.length(0)
expect(container.getSnapshot()).to.equal('head')
})
})
@@ -101,7 +101,7 @@ describe('OfflineDocBackup', function () {
doc.trigger('change')
clock.tick(FLUSH_DELAY)
expect(readRecord()).to.be.null
backup.destroy()
backup.disconnect()
})
it('does not write while online', function () {
@@ -255,7 +255,7 @@ describe('OfflineDocBackup', function () {
expect(record?.snapshot).to.equal('server text updated')
})
it('destroy() removes listeners but preserves the stored record', function () {
it('disconnect() removes listeners but preserves the stored record', function () {
const backup = create()
goOffline()
doc.pendingOp = [{ i: 'x', p: 0 }]
@@ -265,10 +265,10 @@ describe('OfflineDocBackup', function () {
// Teardown on the error/timeout path must not delete the backup: it is what
// recovery reads on reload.
backup.destroy()
backup.disconnect()
expect(readRecord()).to.not.be.null
// No further writes after destroy.
// No further writes after disconnect.
doc.pendingOp = [{ i: 'xy', p: 0 }]
doc.trigger('change')
clock.tick(FLUSH_DELAY)
@@ -286,7 +286,7 @@ describe('OfflineDocBackup', function () {
// Mirror onError(): buffered ops are cleared before teardown runs.
doc.pendingOp = null
doc.inflightOp = null
backup.destroy()
backup.disconnect()
expect(readRecord()).to.not.be.null
})
@@ -305,6 +305,19 @@ describe('OfflineDocBackup', function () {
])
})
it('static remove deletes the stored record for the given doc', function () {
create()
goOffline()
doc.pendingOp = [{ i: 'x', p: 0 }]
doc.trigger('change')
clock.tick(FLUSH_DELAY)
expect(readRecord()).to.not.be.null
OfflineDocBackup.remove(PROJECT_ID, DOC_ID)
expect(readRecord()).to.be.null
})
it('clearAll removes every backup key but leaves unrelated keys', function () {
window.sessionStorage.setItem(
`doc.offline-backup.${USER_ID}.${PROJECT_ID}.${DOC_ID}`,
@@ -0,0 +1,222 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { ShareJsDoc } from '@/features/ide-react/editor/share-js-doc'
import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
import { ShareJsOperation } from '@/features/ide-react/editor/types/document'
import { OfflineDocBackupRecord } from '@/features/ide-react/editor/offline-doc-backup'
const DOC_ID = 'doc-1'
function makeDoc(version = 5) {
const socket = { emit: sinon.stub(), publicId: 'client-1' }
const globalWatchdog = new EditorWatchdogManager({
onTimeoutHandler: () => {},
})
const doc = new ShareJsDoc(
DOC_ID,
[],
version,
socket as unknown as Socket,
globalWatchdog,
{ emit() {} } as unknown as IdeEventEmitter,
'sharejs-text-ot'
)
return { doc, socket }
}
function makeBackup(
overrides: Partial<OfflineDocBackupRecord> = {}
): OfflineDocBackupRecord {
return {
docId: DOC_ID,
projectId: 'project-1',
version: 5,
snapshot: '',
inflightOp: null,
pendingOp: null,
trackChanges: false,
updatedAt: 0,
inflightSubmittedIds: [],
...overrides,
}
}
describe('ShareJsDoc offline recovery', function () {
let clock: sinon.SinonFakeTimers
beforeEach(function () {
window.metaAttributesCache = new Map()
clock = sinon.useFakeTimers()
})
afterEach(function () {
clock.restore()
})
describe('restoreFromOfflineBackup', function () {
it('rebuilds the local view from the baseline plus the buffered ops', function () {
const { doc } = makeDoc(5)
doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
inflightOp: [{ i: 'X', p: 0 }] as ShareJsOperation,
pendingOp: [{ i: 'Y', p: 3 }] as ShareJsOperation,
inflightSubmittedIds: ['old-client'],
})
)
// baseline 'ab' -> inflight inserts 'X' at 0 -> 'Xab' -> pending inserts
// 'Y' at 3 -> 'XabY'
expect(doc.getSnapshot()).to.equal('XabY')
expect(doc.getVersion()).to.equal(5)
expect(doc.getInflightOp()).to.deep.equal([{ i: 'X', p: 0 }])
expect(doc.getPendingOp()).to.deep.equal([{ i: 'Y', p: 3 }])
expect(Array.from(doc.getInflightSubmittedIds())).to.deep.equal([
'old-client',
])
})
it('keeps the baseline snapshot when there are no buffered ops', function () {
const { doc } = makeDoc()
doc.restoreFromOfflineBackup(makeBackup({ snapshot: 'ab' }))
expect(doc.getSnapshot()).to.equal('ab')
expect(doc.getInflightOp()).to.equal(null)
expect(doc.getPendingOp()).to.equal(null)
})
it('applies a lone inflight or pending op', function () {
const inflightOnly = makeDoc()
inflightOnly.doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
inflightOp: [{ i: 'X', p: 0 }] as ShareJsOperation,
})
)
expect(inflightOnly.doc.getSnapshot()).to.equal('Xab')
const pendingOnly = makeDoc()
pendingOnly.doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
pendingOp: [{ i: 'Y', p: 2 }] as ShareJsOperation,
})
)
expect(pendingOnly.doc.getSnapshot()).to.equal('abY')
})
})
describe('sendRecoveredOps', function () {
it('resends a surviving inflight op with dupIfSource', function () {
const { doc, socket } = makeDoc(5)
doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
inflightOp: [{ i: 'X', p: 0 }] as ShareJsOperation,
pendingOp: [{ i: 'Y', p: 3 }] as ShareJsOperation,
inflightSubmittedIds: ['old-client'],
})
)
socket.emit.resetHistory()
doc.sendRecoveredOps()
expect(socket.emit).to.have.been.calledOnce
const [event, docId, update] = socket.emit.getCall(0).args
expect(event).to.equal('applyOtUpdate')
expect(docId).to.equal(DOC_ID)
expect(update.op).to.deep.equal([{ i: 'X', p: 0 }])
expect(update.v).to.equal(5)
expect(update.dupIfSource).to.deep.equal(['old-client'])
})
it('flushes the pending op when nothing is inflight', function () {
const { doc, socket } = makeDoc()
doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
pendingOp: [{ i: 'Y', p: 2 }] as ShareJsOperation,
})
)
socket.emit.resetHistory()
doc.sendRecoveredOps()
expect(socket.emit).to.have.been.calledOnce
const [event, , update] = socket.emit.getCall(0).args
expect(event).to.equal('applyOtUpdate')
expect(update.op).to.deep.equal([{ i: 'Y', p: 2 }])
})
it('drops an inflight op that cancelled out and flushes the pending op instead', function () {
const { doc, socket } = makeDoc(5)
// An inflight op that transformed away during catch-up is left as an empty
// op list rather than null.
doc.restoreFromOfflineBackup(
makeBackup({
snapshot: 'ab',
inflightOp: [] as unknown as ShareJsOperation,
pendingOp: [{ i: 'Y', p: 2 }] as ShareJsOperation,
inflightSubmittedIds: ['old-client'],
})
)
socket.emit.resetHistory()
doc.sendRecoveredOps()
// The empty inflight op is never sent (the server rejects zero-length
// ops); the pending op is flushed instead.
expect(socket.emit).to.have.been.calledOnce
const [event, , update] = socket.emit.getCall(0).args
expect(event).to.equal('applyOtUpdate')
expect(update.op).to.deep.equal([{ i: 'Y', p: 2 }])
// The cancelled op's stale dedup ids are cleared so they don't leak onto
// the flushed pending op.
expect(Array.from(doc.getInflightSubmittedIds())).to.deep.equal([])
})
it('sends nothing when there are no buffered ops', function () {
const { doc, socket } = makeDoc()
doc.restoreFromOfflineBackup(makeBackup({ snapshot: 'ab' }))
socket.emit.resetHistory()
doc.sendRecoveredOps()
expect(socket.emit).to.not.have.been.called
})
})
describe('catchUp', function () {
// A remote delete that doesn't match the snapshot throws when applied; this
// stands in for any replay failure during catch-up. catchUp overwrites `v`,
// and `meta` just needs to be present so the op isn't mistaken for an ack.
const failingUpdate = {
v: 0,
op: [{ d: 'ZZ', p: 0 }],
meta: {},
}
function makeRestoredDoc() {
const { doc } = makeDoc(5)
doc.restoreFromOfflineBackup(makeBackup({ snapshot: 'ab' }))
return doc
}
it('rethrows a replay failure when asked, so recovery can fall back', function () {
const doc = makeRestoredDoc()
expect(() => doc.catchUp([failingUpdate], { rethrow: true })).to.throw()
})
it('reports a replay failure via the error event and swallows it by default', function () {
const doc = makeRestoredDoc()
const onError = sinon.stub()
doc.on('error', onError)
expect(() => doc.catchUp([failingUpdate])).to.not.throw()
expect(onError).to.have.been.called
})
})
})