diff --git a/src/index.js b/src/index.js index 66fe70da..91d34893 100644 --- a/src/index.js +++ b/src/index.js @@ -47,6 +47,7 @@ export { findRootTypeKey, typeListToArraySnapshot, typeMapGetSnapshot, + createDocFromSnapshot, iterateDeletedStructs, applyUpdate, applyUpdateV2, diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index cd47e424..ccd923e8 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -12,13 +12,17 @@ import { createDeleteSet, createID, getState, - AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line + findIndexSS, + UpdateEncoderV2, + DefaultDSEncoder, + applyUpdateV2, + AbstractDSDecoder, AbstractDSEncoder, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line } from '../internals.js' import * as map from 'lib0/map.js' import * as set from 'lib0/set.js' import * as decoding from 'lib0/decoding.js' -import { DefaultDSEncoder } from './encoding.js' +import * as encoding from 'lib0/encoding.js' export class Snapshot { /** @@ -148,3 +152,51 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => { meta.add(snapshot) } } + +/** + * @param {Doc} originDoc + * @param {Snapshot} snapshot + * @param {Doc} [newDoc] Optionally, you may define the Yjs document that receives the data from originDoc + * @return {Doc} + */ +export const createDocFromSnapshot = (originDoc, snapshot, newDoc = new Doc()) => { + if (originDoc.gc) { + // we should not try to restore a GC-ed document, because some of the restored items might have their content deleted + throw new Error('originDoc must not be garbage collected') + } + const { sv, ds } = snapshot + + const encoder = new UpdateEncoderV2() + originDoc.transact(transaction => { + let size = 0 + sv.forEach(clock => { + if (clock > 0) { + size++ + } + }) + encoding.writeVarUint(encoder.restEncoder, size) + // splitting the structs before writing them to the encoder + for (const [client, clock] of sv) { + if (clock === 0) { + continue + } + if (clock < getState(originDoc.store, client)) { + getItemCleanStart(transaction, createID(client, clock)) + } + const structs = originDoc.store.clients.get(client) || [] + const lastStructIndex = findIndexSS(structs, clock - 1) + // write # encoded structs + encoding.writeVarUint(encoder.restEncoder, lastStructIndex + 1) + encoder.writeClient(client) + // first clock written is 0 + encoding.writeVarUint(encoder.restEncoder, 0) + for (let i = 0; i <= lastStructIndex; i++) { + structs[i].write(encoder, 0) + } + } + writeDeleteSet(encoder, ds) + }) + + applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot') + return newDoc +} diff --git a/tests/index.js b/tests/index.js index 710b6edb..aec3ae5a 100644 --- a/tests/index.js +++ b/tests/index.js @@ -7,6 +7,7 @@ import * as encoding from './encoding.tests.js' import * as undoredo from './undo-redo.tests.js' import * as compatibility from './compatibility.tests.js' import * as doc from './doc.tests.js' +import * as snapshot from './snapshot.tests.js' import { runTests } from 'lib0/testing.js' import { isBrowser, isNode } from 'lib0/environment.js' @@ -16,7 +17,7 @@ if (isBrowser) { log.createVConsole(document.body) } runTests({ - doc, map, array, text, xml, encoding, undoredo, compatibility + doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot }).then(success => { /* istanbul ignore next */ if (isNode) { diff --git a/tests/snapshot.tests.js b/tests/snapshot.tests.js new file mode 100644 index 00000000..b52cb67c --- /dev/null +++ b/tests/snapshot.tests.js @@ -0,0 +1,171 @@ +import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals' +import * as t from 'lib0/testing.js' +import { init } from './testHelper' + +/** + * @param {t.TestCase} tc + */ +export const testBasicRestoreSnapshot = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, ['hello']) + const snap = snapshot(doc) + doc.getArray('array').insert(1, ['world']) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toArray(), ['hello']) + t.compare(doc.getArray('array').toArray(), ['hello', 'world']) +} + +/** + * @param {t.TestCase} tc + */ +export const testEmptyRestoreSnapshot = tc => { + const doc = new Doc({ gc: false }) + const snap = snapshot(doc) + snap.sv.set(9999, 0) + doc.getArray().insert(0, ['world']) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray().toArray(), []) + t.compare(doc.getArray().toArray(), ['world']) + + // now this snapshot reflects the latest state. It shoult still work. + const snap2 = snapshot(doc) + const docRestored2 = createDocFromSnapshot(doc, snap2) + t.compare(docRestored2.getArray().toArray(), ['world']) +} + +/** + * @param {t.TestCase} tc + */ +export const testRestoreSnapshotWithSubType = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, [new YMap()]) + const subMap = doc.getArray('array').get(0) + subMap.set('key1', 'value1') + + const snap = snapshot(doc) + subMap.set('key2', 'value2') + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toJSON(), [{ + key1: 'value1' + }]) + t.compare(doc.getArray('array').toJSON(), [{ + key1: 'value1', + key2: 'value2' + }]) +} + +/** + * @param {t.TestCase} tc + */ +export const testRestoreDeletedItem1 = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, ['item1', 'item2']) + + const snap = snapshot(doc) + doc.getArray('array').delete(0) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toArray(), ['item1', 'item2']) + t.compare(doc.getArray('array').toArray(), ['item2']) +} + +/** + * @param {t.TestCase} tc + */ +export const testRestoreLeftItem = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, ['item1']) + doc.getMap('map').set('test', 1) + doc.getArray('array').insert(0, ['item0']) + + const snap = snapshot(doc) + doc.getArray('array').delete(1) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toArray(), ['item0', 'item1']) + t.compare(doc.getArray('array').toArray(), ['item0']) +} + +/** + * @param {t.TestCase} tc + */ +export const testDeletedItemsBase = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, ['item1']) + doc.getArray('array').delete(0) + const snap = snapshot(doc) + doc.getArray('array').insert(0, ['item0']) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toArray(), []) + t.compare(doc.getArray('array').toArray(), ['item0']) +} + +/** + * @param {t.TestCase} tc + */ +export const testDeletedItems2 = tc => { + const doc = new Doc({ gc: false }) + doc.getArray('array').insert(0, ['item1', 'item2', 'item3']) + doc.getArray('array').delete(1) + const snap = snapshot(doc) + doc.getArray('array').insert(0, ['item0']) + + const docRestored = createDocFromSnapshot(doc, snap) + + t.compare(docRestored.getArray('array').toArray(), ['item1', 'item3']) + t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3']) +} + +/** + * @param {t.TestCase} tc + */ +export const testDependentChanges = tc => { + const { array0, array1, testConnector } = init(tc, { users: 2 }) + + if (!array0.doc) { + throw new Error('no document 0') + } + if (!array1.doc) { + throw new Error('no document 1') + } + + /** + * @type Doc + */ + const doc0 = array0.doc + /** + * @type Doc + */ + const doc1 = array1.doc + + doc0.gc = false + doc1.gc = false + + array0.insert(0, ['user1item1']) + testConnector.syncAll() + array1.insert(1, ['user2item1']) + testConnector.syncAll() + + const snap = snapshot(array0.doc) + + array0.insert(2, ['user1item2']) + testConnector.syncAll() + array1.insert(3, ['user2item2']) + testConnector.syncAll() + + const docRestored0 = createDocFromSnapshot(array0.doc, snap) + t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1']) + + const docRestored1 = createDocFromSnapshot(array1.doc, snap) + t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1']) +}