diff --git a/src/index.js b/src/index.js index 90598ba8..74876ddc 100644 --- a/src/index.js +++ b/src/index.js @@ -38,7 +38,8 @@ export { getState, Snapshot, createSnapshot, - createSnapshotFromDoc, + snapshot, + emptySnapshot, findRootTypeKey, typeListToArraySnapshot, typeMapGetSnapshot, @@ -46,5 +47,9 @@ export { applyUpdate, encodeStateAsUpdate, encodeStateVector, - UndoManager + UndoManager, + decodeSnapshot, + encodeSnapshot, + isDeleted, + equalSnapshots } from './internals.js' diff --git a/src/types/AbstractType.js b/src/types/AbstractType.js index 9207c175..82aa8fc8 100644 --- a/src/types/AbstractType.js +++ b/src/types/AbstractType.js @@ -584,7 +584,7 @@ export const typeMapHas = (parent, key) => { */ export const typeMapGetSnapshot = (parent, key, snapshot) => { let v = parent._map.get(key) || null - while (v !== null && (!snapshot.sm.has(v.id.client) || v.id.clock >= (snapshot.sm.get(v.id.client) || 0))) { + while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) { v = v.left } return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined diff --git a/src/utils/DeleteSet.js b/src/utils/DeleteSet.js index 747d8162..ea3b7b59 100644 --- a/src/utils/DeleteSet.js +++ b/src/utils/DeleteSet.js @@ -169,6 +169,8 @@ export const addToDeleteSet = (ds, id, length) => { map.setIfUndefined(ds.clients, id.client, () => []).push(new DeleteItem(id.clock, length)) } +export const createDeleteSet = () => new DeleteSet() + /** * @param {StructStore} ss * @return {DeleteSet} Merged and sorted DeleteSet @@ -177,7 +179,7 @@ export const addToDeleteSet = (ds, id, length) => { * @function */ export const createDeleteSetFromStructStore = ss => { - const ds = new DeleteSet() + const ds = createDeleteSet() ss.clients.forEach((structs, client) => { /** * @type {Array} @@ -224,6 +226,26 @@ export const writeDeleteSet = (encoder, ds) => { }) } +/** + * @param {decoding.Decoder} decoder + * @return {DeleteSet} + * + * @private + * @function + */ +export const readDeleteSet = decoder => { + const ds = new DeleteSet() + const numClients = decoding.readVarUint(decoder) + for (let i = 0; i < numClients; i++) { + const client = decoding.readVarUint(decoder) + const numberOfDeletes = decoding.readVarUint(decoder) + for (let i = 0; i < numberOfDeletes; i++) { + addToDeleteSet(ds, createID(client, decoding.readVarUint(decoder)), decoding.readVarUint(decoder)) + } + } + return ds +} + /** * @param {decoding.Decoder} decoder * @param {Transaction} transaction @@ -232,7 +254,7 @@ export const writeDeleteSet = (encoder, ds) => { * @private * @function */ -export const readDeleteSet = (decoder, transaction, store) => { +export const readAndApplyDeleteSet = (decoder, transaction, store) => { const unappliedDS = new DeleteSet() const numClients = decoding.readVarUint(decoder) for (let i = 0; i < numClients; i++) { @@ -279,6 +301,7 @@ export const readDeleteSet = (decoder, transaction, store) => { } } if (unappliedDS.clients.size > 0) { + // TODO: no need for encoding+decoding ds anymore const unappliedDSEncoder = encoding.createEncoder() writeDeleteSet(unappliedDSEncoder, unappliedDS) store.pendingDeleteReaders.push(decoding.createDecoder(encoding.toUint8Array(unappliedDSEncoder))) diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js index 3fb807d2..f9d2714e 100644 --- a/src/utils/Snapshot.js +++ b/src/utils/Snapshot.js @@ -6,18 +6,26 @@ import { getItemCleanStart, createID, iterateDeletedStructs, + writeDeleteSet, + writeStateVector, + readDeleteSet, + readStateVector, + createDeleteSet, + getState, 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 encoding from 'lib0/encoding.js' +import * as decoding from 'lib0/decoding.js' export class Snapshot { /** * @param {DeleteSet} ds - * @param {Map} sm state map + * @param {Map} sv state map */ - constructor (ds, sm) { + constructor (ds, sv) { /** * @type {DeleteSet} * @private @@ -28,10 +36,64 @@ export class Snapshot { * @type {Map} * @private */ - this.sm = sm + this.sv = sv } } +/** + * @param {Snapshot} snap1 + * @param {Snapshot} snap2 + * @return {boolean} + */ +export const equalSnapshots = (snap1, snap2) => { + const ds1 = snap1.ds.clients + const ds2 = snap2.ds.clients + const sv1 = snap1.sv + const sv2 = snap2.sv + if (sv1.size !== sv2.size || ds1.size !== ds2.size) { + return false + } + for (const [key, value] of sv1) { + if (sv2.get(key) !== value) { + return false + } + } + for (const [client, dsitems1] of ds1) { + const dsitems2 = ds2.get(client) || [] + if (dsitems1.length !== dsitems2.length) { + return false + } + for (let i = 0; i < dsitems1.length; i++) { + const dsitem1 = dsitems1[i] + const dsitem2 = dsitems2[i] + if (dsitem1.clock !== dsitem2.clock || dsitem1.len !== dsitem2.len) { + return false + } + } + } + return true +} + +/** + * @param {Snapshot} snapshot + * @return {Uint8Array} + */ +export const encodeSnapshot = snapshot => { + const encoder = encoding.createEncoder() + writeDeleteSet(encoder, snapshot.ds) + writeStateVector(encoder, snapshot.sv) + return encoding.toUint8Array(encoder) +} + +/** + * @param {Uint8Array} buf + * @return {Snapshot} + */ +export const decodeSnapshot = buf => { + const decoder = decoding.createDecoder(buf) + return new Snapshot(readDeleteSet(decoder), readStateVector(decoder)) +} + /** * @param {DeleteSet} ds * @param {Map} sm @@ -39,11 +101,13 @@ export class Snapshot { */ export const createSnapshot = (ds, sm) => new Snapshot(ds, sm) +export const emptySnapshot = createSnapshot(createDeleteSet(), new Map()) + /** * @param {Doc} doc * @return {Snapshot} */ -export const createSnapshotFromDoc = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store)) +export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store)) /** * @param {Item} item @@ -53,7 +117,7 @@ export const createSnapshotFromDoc = doc => createSnapshot(createDeleteSetFromSt * @function */ export const isVisible = (item, snapshot) => snapshot === undefined ? !item.deleted : ( - snapshot.sm.has(item.id.client) && (snapshot.sm.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id) + snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id) ) /** @@ -65,8 +129,10 @@ export const splitSnapshotAffectedStructs = (transaction, snapshot) => { const store = transaction.doc.store // check if we already split for this snapshot if (!meta.has(snapshot)) { - snapshot.sm.forEach((clock, client) => { - getItemCleanStart(transaction, store, createID(client, clock)) + snapshot.sv.forEach((clock, client) => { + if (clock < getState(store, client)) { + getItemCleanStart(transaction, store, createID(client, clock)) + } }) iterateDeletedStructs(transaction, snapshot.ds, store, item => {}) meta.add(snapshot) diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 63435563..05429ee7 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -1,9 +1,12 @@ import { isDeleted, - AbstractType, Transaction, AbstractStruct // eslint-disable-line + Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line } from '../internals.js' +import * as set from 'lib0/set.js' +import * as array from 'lib0/array.js' + /** * YEvent describes the changes on a YType. */ @@ -28,6 +31,10 @@ export class YEvent { * @type {Transaction} */ this.transaction = transaction + /** + * @type {Object|null} + */ + this._changes = null } /** @@ -65,6 +72,113 @@ export class YEvent { adds (struct) { return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0) } + + /** + * @return {{added:Set,deleted:Set,delta:Array<{insert:Array}|{delete:number}|{retain:number}>}} + */ + get changes () { + let changes = this._changes + if (changes === null) { + const target = this.target + const added = set.create() + const deleted = set.create() + /** + * @type {Array<{insert:Array}|{delete:number}|{retain:number}>} + */ + const delta = [] + /** + * @type {Map} + */ + const keys = new Map() + changes = { + added, deleted, delta, keys + } + const changed = /** @type Set */ (this.transaction.changed.get(target)) + if (changed.has(null)) { + /** + * @type {any} + */ + let lastOp = null + const packOp = () => { + if (lastOp) { + delta.push(lastOp) + } + } + for (let item = target._start; item !== null; item = item.right) { + if (item.deleted) { + if (this.deletes(item)) { + if (lastOp === null || lastOp.delete === undefined) { + packOp() + lastOp = { delete: 0 } + } + lastOp.delete += item.length + deleted.add(item) + } // else nop + } else { + if (this.adds(item)) { + if (lastOp === null || lastOp.insert === undefined) { + packOp() + lastOp = { insert: [] } + } + lastOp.insert = lastOp.insert.concat(item.content.getContent()) + added.add(item) + } else { + if (lastOp === null || lastOp.retain === undefined) { + packOp() + lastOp = { retain: 0 } + } + lastOp.retain += item.length + } + } + } + if (lastOp !== null && lastOp.retain === undefined) { + packOp() + } + } + changed.forEach(key => { + if (key !== null) { + const item = /** @type {Item} */ (target._map.get(key)) + /** + * @type {'delete' | 'add' | 'update'} + */ + let action + let oldValue + if (this.adds(item)) { + let prev = item.left + while (prev !== null && this.adds(prev)) { + prev = prev.left + } + if (this.deletes(item)) { + if (prev !== null && this.deletes(prev)) { + action = 'delete' + oldValue = array.last(prev.content.getContent()) + } else { + return + } + } else { + if (prev !== null && this.deletes(prev)) { + action = 'update' + oldValue = array.last(prev.content.getContent()) + } else { + action = 'add' + oldValue = undefined + } + } + } else { + if (this.deletes(item)) { + action = 'delete' + oldValue = array.last(/** @type {Item} */ item.content.getContent()) + } else { + return // nop + } + } + keys.set(key, { action, oldValue }) + } + }) + this._changes = changes + } + return changes + } } /** diff --git a/src/utils/encoding.js b/src/utils/encoding.js index c4ee0d2a..a0ebf23f 100644 --- a/src/utils/encoding.js +++ b/src/utils/encoding.js @@ -23,7 +23,7 @@ import { readID, getState, getStateVector, - readDeleteSet, + readAndApplyDeleteSet, writeDeleteSet, createDeleteSetFromStructStore, Doc, Transaction, AbstractStruct, StructStore, ID // eslint-disable-line @@ -230,7 +230,7 @@ export const tryResumePendingDeleteReaders = (transaction, store) => { const pendingReaders = store.pendingDeleteReaders store.pendingDeleteReaders = [] for (let i = 0; i < pendingReaders.length; i++) { - readDeleteSet(pendingReaders[i], transaction, store) + readAndApplyDeleteSet(pendingReaders[i], transaction, store) } } @@ -301,7 +301,7 @@ export const readStructs = (decoder, transaction, store) => { export const readUpdate = (decoder, ydoc, transactionOrigin) => ydoc.transact(transaction => { readStructs(decoder, transaction, ydoc.store) - readDeleteSet(decoder, transaction, ydoc.store) + readAndApplyDeleteSet(decoder, transaction, ydoc.store) }, transactionOrigin) /** @@ -381,6 +381,22 @@ export const readStateVector = decoder => { */ export const decodeStateVector = decodedState => readStateVector(decoding.createDecoder(decodedState)) +/** + * Write State Vector to `lib0/encoding.js#Encoder`. + * + * @param {encoding.Encoder} encoder + * @param {Map} sv + * @function + */ +export const writeStateVector = (encoder, sv) => { + encoding.writeVarUint(encoder, sv.size) + sv.forEach((clock, client) => { + encoding.writeVarUint(encoder, client) + encoding.writeVarUint(encoder, clock) + }) + return encoder +} + /** * Write State Vector to `lib0/encoding.js#Encoder`. * @@ -389,16 +405,7 @@ export const decodeStateVector = decodedState => readStateVector(decoding.create * * @function */ -export const writeDocumentStateVector = (encoder, doc) => { - encoding.writeVarUint(encoder, doc.store.clients.size) - doc.store.clients.forEach((structs, client) => { - const struct = structs[structs.length - 1] - const id = struct.id - encoding.writeVarUint(encoder, id.client) - encoding.writeVarUint(encoder, id.clock + struct.length) - }) - return encoder -} +export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store)) /** * Encode State as Uint8Array. diff --git a/tests/y-array.tests.js b/tests/y-array.tests.js index b3bb8454..953e862f 100644 --- a/tests/y-array.tests.js +++ b/tests/y-array.tests.js @@ -191,6 +191,33 @@ export const testInsertAndDeleteEventsForTypes = tc => { compare(users) } +/** + * @param {t.TestCase} tc + */ +export const testChangeEvent = tc => { + const { array0, users } = init(tc, { users: 2 }) + /** + * @type {any} + */ + let changes = null + array0.observe(e => { + changes = e.changes + }) + const newArr = new Y.Array() + array0.insert(0, [newArr, 4, 'dtrn']) + t.assert(changes !== null && changes.added.size === 2 && changes.deleted.size === 0) + t.compare(changes.delta, [{insert: [newArr, 4, 'dtrn']}]) + changes = null + array0.delete(0, 2) + t.assert(changes !== null && changes.added.size === 0 && changes.deleted.size === 2) + t.compare(changes.delta, [{ delete: 2 }]) + changes = null + array0.insert(1, [0.1]) + t.assert(changes !== null && changes.added.size === 1 && changes.deleted.size === 0) + t.compare(changes.delta, [{ retain: 1 }, { insert: [0.1] }]) + compare(users) +} + /** * @param {t.TestCase} tc */ diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js index c47c9e46..becfde34 100644 --- a/tests/y-map.tests.js +++ b/tests/y-map.tests.js @@ -292,6 +292,54 @@ export const testThrowsAddAndUpdateAndDeleteEvents = tc => { compare(users) } +/** + * @param {t.TestCase} tc + */ +export const testChangeEvent = tc => { + const { map0, users } = init(tc, { users: 2 }) + /** + * @type {any} + */ + let changes = null + /** + * @type {any} + */ + let keyChange = null + map0.observe(e => { + changes = e.changes + }) + map0.set('a', 1) + keyChange = changes.keys.get('a') + t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined) + map0.set('a', 2) + keyChange = changes.keys.get('a') + t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 1) + users[0].transact(() => { + map0.set('a', 3) + map0.set('a', 4) + }) + keyChange = changes.keys.get('a') + t.assert(changes !== null && keyChange.action === 'update' && keyChange.oldValue === 2) + users[0].transact(() => { + map0.set('b', 1) + map0.set('b', 2) + }) + keyChange = changes.keys.get('b') + t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined) + users[0].transact(() => { + map0.set('c', 1) + map0.delete('c') + }) + t.assert(changes !== null && changes.keys.size === 0) + users[0].transact(() => { + map0.set('d', 1) + map0.set('d', 2) + }) + keyChange = changes.keys.get('d') + t.assert(changes !== null && keyChange.action === 'add' && keyChange.oldValue === undefined) + compare(users) +} + /** * @param {t.TestCase} tc */ diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js index 1c9b0a85..f422ed45 100644 --- a/tests/y-text.tests.js +++ b/tests/y-text.tests.js @@ -98,7 +98,7 @@ export const testSnapshot = tc => { text0.applyDelta([{ insert: 'abcd' }]) - const snapshot1 = Y.createSnapshotFromDoc(doc0) + const snapshot1 = Y.snapshot(doc0) text0.applyDelta([{ retain: 1 }, { @@ -106,7 +106,7 @@ export const testSnapshot = tc => { }, { delete: 1 }]) - const snapshot2 = Y.createSnapshotFromDoc(doc0) + const snapshot2 = Y.snapshot(doc0) text0.applyDelta([{ retain: 2 }, { @@ -140,7 +140,7 @@ export const testSnapshotDeleteAfter = tc => { text0.applyDelta([{ insert: 'abcd' }]) - const snapshot1 = Y.createSnapshotFromDoc(doc0) + const snapshot1 = Y.snapshot(doc0) text0.applyDelta([{ retain: 4 }, {