From ceba4b1837a3ae9c60f73fa75257d5cfcc43faec Mon Sep 17 00:00:00 2001 From: calibr Date: Wed, 22 Jul 2020 18:35:03 +0300 Subject: [PATCH 1/4] restoring document to a specific state using a Snapshot, #159 --- src/index.js | 1 + src/utils/DeleteSet.js | 70 +++++++++++++--------- src/utils/Snapshot.js | 126 ++++++++++++++++++++++++++++++++++++++++ tests/index.js | 3 +- tests/snapshot.tests.js | 110 +++++++++++++++++++++++++++++++++++ 5 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 tests/snapshot.tests.js 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/DeleteSet.js b/src/utils/DeleteSet.js index 658f5087..5a6d524c 100644 --- a/src/utils/DeleteSet.js +++ b/src/utils/DeleteSet.js @@ -278,37 +278,13 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => { const state = getState(store, client) for (let i = 0; i < numberOfDeletes; i++) { const clock = decoder.readDsClock() - const clockEnd = clock + decoder.readDsLen() + const len = decoder.readDsLen() + const clockEnd = clock + len if (clock < state) { if (state < clockEnd) { addToDeleteSet(unappliedDS, client, state, clockEnd - state) } - let index = findIndexSS(structs, clock) - /** - * We can ignore the case of GC and Delete structs, because we are going to skip them - * @type {Item} - */ - // @ts-ignore - let struct = structs[index] - // split the first item if necessary - if (!struct.deleted && struct.id.clock < clock) { - structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)) - index++ // increase we now want to use the next struct - } - while (index < structs.length) { - // @ts-ignore - struct = structs[index++] - if (struct.id.clock < clockEnd) { - if (!struct.deleted) { - if (clockEnd < struct.id.clock + struct.length) { - structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock)) - } - struct.delete(transaction) - } - } else { - break - } - } + applyDeleteItem(transaction, structs, { clock, len }) } else { addToDeleteSet(unappliedDS, client, clock, clockEnd - clock) } @@ -321,3 +297,43 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => { store.pendingDeleteReaders.push(new DSDecoderV2(decoding.createDecoder((unappliedDSEncoder.toUint8Array())))) } } + +/** + * Applies a DeleteItem on a document + * + * @param {Transaction} transaction + * @param {Array} structs + * @param {DeleteItem} deleteItem + * + * @private + * @function + */ +export const applyDeleteItem = (transaction, structs, { clock, len }) => { + const clockEnd = clock + len + let index = findIndexSS(structs, clock) + /** + * We can ignore the case of GC and Delete structs, because we are going to skip them + * @type {Item} + */ + // @ts-ignore + let struct = structs[index] + // split the first item if necessary + if (!struct.deleted && struct.id.clock < clock) { + structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)) + index++ // increase we now want to use the next struct + } + while (index < structs.length) { + // @ts-ignore + struct = structs[index++] + if (struct.id.clock < clockEnd) { + if (!struct.deleted) { + if (clockEnd < struct.id.clock + struct.length) { + structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock)) + } + struct.delete(transaction) + } + } else { + break + } + } +} diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index cd47e424..c8140609 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -3,6 +3,7 @@ import { isDeleted, createDeleteSetFromStructStore, getStateVector, + getItem, getItemCleanStart, iterateDeletedStructs, writeDeleteSet, @@ -11,7 +12,11 @@ import { readStateVector, createDeleteSet, createID, + ID, getState, + findIndexCleanStart, + AbstractStruct, + applyDeleteItem, AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line } from '../internals.js' @@ -148,3 +153,124 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => { meta.add(snapshot) } } + +/** + * @param {Doc} originDoc + * @param {Snapshot} snapshot + * @return {Doc} + */ +export const createDocFromSnapshot = (originDoc, snapshot) => { + 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 needState = new Map(sv) + + let len = 0 + const tempStructs = [] + /** + * State Map + * @type any[] + */ + const itemsToIntegrate = [] + originDoc.transact(transaction => { + for (let user of needState.keys()) { + let clock = needState.get(user) || 0 + const userItems = originDoc.store.clients.get(user) + if (!userItems) { + continue + } + + let lastIndex + const lastItem = userItems[userItems.length - 1] + if (clock === lastItem.id.clock + lastItem.length) { + lastIndex = lastItem.id.clock + lastItem.length + 1 + } else { + lastIndex = findIndexCleanStart(transaction, userItems, clock) + } + for (let i = 0; i < lastIndex; i++) { + const item = userItems[i] + if (item instanceof Item) { + itemsToIntegrate.push({ + id: item.id, + left: item.left ? item.left.id : null, + right: item.right ? item.right.id : null, + origin: item.origin ? createID(item.origin.client, item.origin.clock) : null, + rightOrigin: item.rightOrigin ? createID(item.rightOrigin.client, item.rightOrigin.clock) : null, + parent: item.parent, + parentSub: item.parentSub, + content: item.content.copy() + }) + } + } + } + }) + + const newDoc = new Doc() + + // copy root types + const sharedKeysByValue = new Map() + for (const [key, t] of originDoc.share) { + const Constructor = t.constructor + newDoc.get(key, Constructor) + sharedKeysByValue.set(t, key) + } + + let lastId = new Map() + /** + * @param {ID} id + * @return {Item|null} + */ + const getItemSafe = (id) => { + if (!lastId.has(id.client)) { + return null + } + if (lastId.get(id.client) < id.clock) { + return null + } + return getItem(newDoc.store, id) + } + newDoc.transact(transaction => { + for (const item of itemsToIntegrate) { + let parent = null + let left = null + let right = null + const sharedKey = sharedKeysByValue.get(item.parent) + if (sharedKey) { + parent = newDoc.get(sharedKey) + } else if (item.parent) { + parent = getItem(newDoc.store, item.parent._item.id).content.type + } + if (item.left) { + left = getItemSafe(item.left) + } + if (item.right) { + right = getItemSafe(item.right) + } + lastId.set(item.id.client, item.id.clock) + const newItem = new Item( + item.id, + left, + item.origin, + right, + item.rightOrigin, + parent, // not sure + item.parentSub, + item.content + ) + newItem.integrate(transaction, 0) + } + + for (const [client, deleteItems] of ds.clients) { + for (const deleteItem of deleteItems) { + const items = newDoc.store.clients.get(client) + if (items) { + applyDeleteItem(transaction, items, deleteItem) + } + } + } + }) + + return newDoc +} \ No newline at end of file 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..4a831e96 --- /dev/null +++ b/tests/snapshot.tests.js @@ -0,0 +1,110 @@ +import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals' +import * as t from 'lib0/testing.js' + +/** + * @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 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']) +} + From eee695eeeb11e3e6c988216fc53359c7df7a7bf8 Mon Sep 17 00:00:00 2001 From: calibr Date: Tue, 8 Sep 2020 13:32:02 +0300 Subject: [PATCH 2/4] use encoding/decoding for restoring snapshots --- src/utils/DeleteSet.js | 70 +++++++++------------- src/utils/Snapshot.js | 121 +++++++++++---------------------------- src/utils/StructStore.js | 6 ++ tests/snapshot.tests.js | 47 ++++++++++++++- 4 files changed, 111 insertions(+), 133 deletions(-) diff --git a/src/utils/DeleteSet.js b/src/utils/DeleteSet.js index 5a6d524c..658f5087 100644 --- a/src/utils/DeleteSet.js +++ b/src/utils/DeleteSet.js @@ -278,13 +278,37 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => { const state = getState(store, client) for (let i = 0; i < numberOfDeletes; i++) { const clock = decoder.readDsClock() - const len = decoder.readDsLen() - const clockEnd = clock + len + const clockEnd = clock + decoder.readDsLen() if (clock < state) { if (state < clockEnd) { addToDeleteSet(unappliedDS, client, state, clockEnd - state) } - applyDeleteItem(transaction, structs, { clock, len }) + let index = findIndexSS(structs, clock) + /** + * We can ignore the case of GC and Delete structs, because we are going to skip them + * @type {Item} + */ + // @ts-ignore + let struct = structs[index] + // split the first item if necessary + if (!struct.deleted && struct.id.clock < clock) { + structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)) + index++ // increase we now want to use the next struct + } + while (index < structs.length) { + // @ts-ignore + struct = structs[index++] + if (struct.id.clock < clockEnd) { + if (!struct.deleted) { + if (clockEnd < struct.id.clock + struct.length) { + structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock)) + } + struct.delete(transaction) + } + } else { + break + } + } } else { addToDeleteSet(unappliedDS, client, clock, clockEnd - clock) } @@ -297,43 +321,3 @@ export const readAndApplyDeleteSet = (decoder, transaction, store) => { store.pendingDeleteReaders.push(new DSDecoderV2(decoding.createDecoder((unappliedDSEncoder.toUint8Array())))) } } - -/** - * Applies a DeleteItem on a document - * - * @param {Transaction} transaction - * @param {Array} structs - * @param {DeleteItem} deleteItem - * - * @private - * @function - */ -export const applyDeleteItem = (transaction, structs, { clock, len }) => { - const clockEnd = clock + len - let index = findIndexSS(structs, clock) - /** - * We can ignore the case of GC and Delete structs, because we are going to skip them - * @type {Item} - */ - // @ts-ignore - let struct = structs[index] - // split the first item if necessary - if (!struct.deleted && struct.id.clock < clock) { - structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock)) - index++ // increase we now want to use the next struct - } - while (index < structs.length) { - // @ts-ignore - struct = structs[index++] - if (struct.id.clock < clockEnd) { - if (!struct.deleted) { - if (clockEnd < struct.id.clock + struct.length) { - structs.splice(index, 0, splitItem(transaction, struct, clockEnd - struct.id.clock)) - } - struct.delete(transaction) - } - } else { - break - } - } -} diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index c8140609..791b43fa 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -16,13 +16,17 @@ import { getState, findIndexCleanStart, AbstractStruct, - applyDeleteItem, + writeClientsStructs, + findIndexSS, + readUpdateV2, + UpdateEncoderV2, UpdateDecoderV2, AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, 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 * as encoding from 'lib0/encoding.js' import { DefaultDSEncoder } from './encoding.js' export class Snapshot { @@ -174,103 +178,42 @@ export const createDocFromSnapshot = (originDoc, snapshot) => { * @type any[] */ const itemsToIntegrate = [] + + /** + * @type Uint8Array + */ + let updateBuffer = new Uint8Array() originDoc.transact(transaction => { - for (let user of needState.keys()) { - let clock = needState.get(user) || 0 - const userItems = originDoc.store.clients.get(user) - if (!userItems) { - continue + const encoder = new UpdateEncoderV2() + + encoding.writeVarUint(encoder.restEncoder, sv.size) + // splitting the structs before writing them to the encoder + for (const [client, clock] of sv) { + if (clock < getState(originDoc.store, client)) { + getItemCleanStart(transaction, createID(client, clock)) } - let lastIndex - const lastItem = userItems[userItems.length - 1] - if (clock === lastItem.id.clock + lastItem.length) { - lastIndex = lastItem.id.clock + lastItem.length + 1 - } else { - lastIndex = findIndexCleanStart(transaction, userItems, clock) - } - for (let i = 0; i < lastIndex; i++) { - const item = userItems[i] - if (item instanceof Item) { - itemsToIntegrate.push({ - id: item.id, - left: item.left ? item.left.id : null, - right: item.right ? item.right.id : null, - origin: item.origin ? createID(item.origin.client, item.origin.clock) : null, - rightOrigin: item.rightOrigin ? createID(item.rightOrigin.client, item.rightOrigin.clock) : null, - parent: item.parent, - parentSub: item.parentSub, - content: item.content.copy() - }) - } + 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) + encoding.writeVarUint(encoder.restEncoder, structs[0].id.clock) + const firstStruct = structs[0] + firstStruct.write(encoder, 0) + for (let i = 1; i <= lastStructIndex; i++) { + structs[i].write(encoder, 0) } } + + writeDeleteSet(encoder, ds) + + updateBuffer = encoder.toUint8Array() }) const newDoc = new Doc() - // copy root types - const sharedKeysByValue = new Map() - for (const [key, t] of originDoc.share) { - const Constructor = t.constructor - newDoc.get(key, Constructor) - sharedKeysByValue.set(t, key) - } - - let lastId = new Map() - /** - * @param {ID} id - * @return {Item|null} - */ - const getItemSafe = (id) => { - if (!lastId.has(id.client)) { - return null - } - if (lastId.get(id.client) < id.clock) { - return null - } - return getItem(newDoc.store, id) - } - newDoc.transact(transaction => { - for (const item of itemsToIntegrate) { - let parent = null - let left = null - let right = null - const sharedKey = sharedKeysByValue.get(item.parent) - if (sharedKey) { - parent = newDoc.get(sharedKey) - } else if (item.parent) { - parent = getItem(newDoc.store, item.parent._item.id).content.type - } - if (item.left) { - left = getItemSafe(item.left) - } - if (item.right) { - right = getItemSafe(item.right) - } - lastId.set(item.id.client, item.id.clock) - const newItem = new Item( - item.id, - left, - item.origin, - right, - item.rightOrigin, - parent, // not sure - item.parentSub, - item.content - ) - newItem.integrate(transaction, 0) - } - - for (const [client, deleteItems] of ds.clients) { - for (const deleteItem of deleteItems) { - const items = newDoc.store.clients.get(client) - if (items) { - applyDeleteItem(transaction, items, deleteItem) - } - } - } - }) + readUpdateV2(decoding.createDecoder(updateBuffer), newDoc, 'snapshot') return newDoc } \ No newline at end of file diff --git a/src/utils/StructStore.js b/src/utils/StructStore.js index f2c7fd8a..95fbcafb 100644 --- a/src/utils/StructStore.js +++ b/src/utils/StructStore.js @@ -124,6 +124,9 @@ export const findIndexSS = (structs, clock) => { let left = 0 let right = structs.length - 1 let mid = structs[right] + if (!mid) { + console.log('\n\nMID!', right, clock, structs.length, mid, '\n\n') + } let midclock = mid.id.clock if (midclock === clock) { return right @@ -134,6 +137,9 @@ export const findIndexSS = (structs, clock) => { let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search while (left <= right) { mid = structs[midindex] + if (!mid) { + console.log('\n\n2', midindex, clock, '\n\n') + } midclock = mid.id.clock if (midclock <= clock) { if (clock < midclock + mid.length) { diff --git a/tests/snapshot.tests.js b/tests/snapshot.tests.js index 4a831e96..0d2c49b0 100644 --- a/tests/snapshot.tests.js +++ b/tests/snapshot.tests.js @@ -1,5 +1,6 @@ import { createDocFromSnapshot, Doc, snapshot, YMap } from '../src/internals' import * as t from 'lib0/testing.js' +import { init } from './testHelper' /** * @param {t.TestCase} tc @@ -91,7 +92,6 @@ export const testDeletedItemsBase = tc => { } - /** * @param {t.TestCase} tc */ @@ -108,3 +108,48 @@ export const testDeletedItems2 = tc => { 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']) +} + From fef3fc2a4ac0cf6c558817d3b1b7b6eeffec002d Mon Sep 17 00:00:00 2001 From: calibr Date: Tue, 8 Sep 2020 13:33:41 +0300 Subject: [PATCH 3/4] remove debug messages --- src/utils/StructStore.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/utils/StructStore.js b/src/utils/StructStore.js index 95fbcafb..f2c7fd8a 100644 --- a/src/utils/StructStore.js +++ b/src/utils/StructStore.js @@ -124,9 +124,6 @@ export const findIndexSS = (structs, clock) => { let left = 0 let right = structs.length - 1 let mid = structs[right] - if (!mid) { - console.log('\n\nMID!', right, clock, structs.length, mid, '\n\n') - } let midclock = mid.id.clock if (midclock === clock) { return right @@ -137,9 +134,6 @@ export const findIndexSS = (structs, clock) => { let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search while (left <= right) { mid = structs[midindex] - if (!mid) { - console.log('\n\n2', midindex, clock, '\n\n') - } midclock = mid.id.clock if (midclock <= clock) { if (clock < midclock + mid.length) { From e1a2ccd7f63f8a25c4805fd689cfe5fb4753e703 Mon Sep 17 00:00:00 2001 From: Kevin Jahns Date: Mon, 28 Sep 2020 18:32:24 +0200 Subject: [PATCH 4/4] add tests to snapshots case and fix the case of empty ranges --- src/utils/Snapshot.js | 61 +++++++++++++++-------------------------- tests/snapshot.tests.js | 24 +++++++++++++--- 2 files changed, 42 insertions(+), 43 deletions(-) diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index 791b43fa..ccd923e8 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -3,7 +3,6 @@ import { isDeleted, createDeleteSetFromStructStore, getStateVector, - getItem, getItemCleanStart, iterateDeletedStructs, writeDeleteSet, @@ -12,22 +11,18 @@ import { readStateVector, createDeleteSet, createID, - ID, getState, - findIndexCleanStart, - AbstractStruct, - writeClientsStructs, findIndexSS, - readUpdateV2, - UpdateEncoderV2, UpdateDecoderV2, - AbstractDSDecoder, AbstractDSEncoder, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item // eslint-disable-line + 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 * as encoding from 'lib0/encoding.js' -import { DefaultDSEncoder } from './encoding.js' export class Snapshot { /** @@ -161,59 +156,47 @@ export const splitSnapshotAffectedStructs = (transaction, 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) => { +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 needState = new Map(sv) - let len = 0 - const tempStructs = [] - /** - * State Map - * @type any[] - */ - const itemsToIntegrate = [] - - /** - * @type Uint8Array - */ - let updateBuffer = new Uint8Array() + const encoder = new UpdateEncoderV2() originDoc.transact(transaction => { - const encoder = new UpdateEncoderV2() - - encoding.writeVarUint(encoder.restEncoder, sv.size) + 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) - encoding.writeVarUint(encoder.restEncoder, structs[0].id.clock) - const firstStruct = structs[0] - firstStruct.write(encoder, 0) - for (let i = 1; i <= lastStructIndex; i++) { + // 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) - - updateBuffer = encoder.toUint8Array() }) - const newDoc = new Doc() - - readUpdateV2(decoding.createDecoder(updateBuffer), newDoc, 'snapshot') - + applyUpdateV2(newDoc, encoder.toUint8Array(), 'snapshot') return newDoc -} \ No newline at end of file +} diff --git a/tests/snapshot.tests.js b/tests/snapshot.tests.js index 0d2c49b0..b52cb67c 100644 --- a/tests/snapshot.tests.js +++ b/tests/snapshot.tests.js @@ -17,6 +17,26 @@ export const testBasicRestoreSnapshot = tc => { 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 */ @@ -74,7 +94,6 @@ export const testRestoreLeftItem = tc => { t.compare(doc.getArray('array').toArray(), ['item0']) } - /** * @param {t.TestCase} tc */ @@ -91,7 +110,6 @@ export const testDeletedItemsBase = tc => { t.compare(doc.getArray('array').toArray(), ['item0']) } - /** * @param {t.TestCase} tc */ @@ -108,7 +126,6 @@ export const testDeletedItems2 = tc => { t.compare(doc.getArray('array').toArray(), ['item0', 'item1', 'item3']) } - /** * @param {t.TestCase} tc */ @@ -152,4 +169,3 @@ export const testDependentChanges = tc => { const docRestored1 = createDocFromSnapshot(array1.doc, snap) t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1']) } -