Compare commits

...

27 Commits

Author SHA1 Message Date
Kevin Jahns
86f7631d1e 13.4.3 2020-11-04 00:37:24 +01:00
Kevin Jahns
3bb107504f fix superflous event happening in nested event system 2020-11-04 00:35:08 +01:00
Kevin Jahns
39803c1d11 13.4.2 2020-10-31 03:58:59 +01:00
Kevin Jahns
46fae57036 Merge pull request #244 from hanspagel/patch-1
fix a small typo (at it heart -> at its heart)
2020-10-31 03:51:17 +01:00
Kevin Jahns
e9cb07da55 Failsafe when splitting surrogate pairs - fixes #248 2020-10-31 02:05:33 +01:00
Kevin Jahns
114f28f48e log error when removing eventhandler that doesnt exist - implements #246 2020-10-31 00:34:19 +01:00
Kevin Jahns
a1da486c8a Merge branch 'main' of github.com:yjs/yjs into main 2020-10-29 12:40:48 +01:00
Kevin Jahns
4fb9cc2a30 fire top-level events first 2020-10-29 12:40:39 +01:00
Kevin Jahns
e2c9eb7f01 13.4.1 2020-10-10 16:53:31 +02:00
Kevin Jahns
6fd33c0720 fix permanent user-data init with new DS-decoder - fixes yjs/y-websocket#33 2020-10-10 16:48:43 +02:00
Hans Pagel
72f3ce75b2 fix a small typo (at it heart -> at its heart) 2020-09-28 23:29:09 +02:00
Kevin Jahns
fd211731cc 13.4.0 2020-09-28 19:04:58 +02:00
Kevin Jahns
8049776074 fix double undo - fixes #241 2020-09-28 19:00:13 +02:00
Kevin Jahns
32b1338d48 Merge pull request #233 from rideg/add_typing_232
Amend typing of YEvent.changes, fixes #232
2020-09-28 18:38:03 +02:00
Kevin Jahns
c2f0ca3fae Merge pull request #238 from johnrees/patch-1
Fix typo in README example
2020-09-28 18:36:35 +02:00
Kevin Jahns
dfc6b879de Merge pull request #239 from yjs/subdocs
implemented first subdocuments draft #234
2020-09-28 18:35:43 +02:00
Kevin Jahns
81f16ff0b5 Merge pull request #243 from yjs/create-doc-from-snapshot-3
Create doc from snapshot 3
2020-09-28 18:34:01 +02:00
Kevin Jahns
e1a2ccd7f6 add tests to snapshots case and fix the case of empty ranges 2020-09-28 18:32:24 +02:00
Kevin Jahns
be8cc8a20c Merge branch '159-create-doc-from-snapshot-2' of git://github.com/calibr/yjs into calibr-159-create-doc-from-snapshot-2 2020-09-28 17:57:51 +02:00
Kevin Jahns
a253cfc090 Merge pull request #235 from DeepAnchor/patch-1
Fix JSDoc annotation
2020-09-28 17:55:25 +02:00
calibr
fef3fc2a4a remove debug messages 2020-09-08 13:33:41 +03:00
calibr
eee695eeeb use encoding/decoding for restoring snapshots 2020-09-08 13:32:02 +03:00
John Rees
38e38a92dc Fix typo in README example 2020-09-04 11:30:01 +01:00
DeepAnchor
7193ae63b7 Fix JSDoc annotation 2020-08-25 13:09:34 -07:00
rideg
4d48224518 Add typing 2020-08-24 09:57:38 -07:00
Kevin Jahns
9c0d1eb209 Merge branch '159-create-doc-from-snapshot-2' of git://github.com/calibr/yjs into calibr-159-create-doc-from-snapshot-2 2020-08-08 12:03:50 +02:00
calibr
ceba4b1837 restoring document to a specific state using a Snapshot, #159 2020-07-27 03:56:32 +03:00
20 changed files with 386 additions and 22 deletions

View File

@@ -12,7 +12,7 @@ which aren't described in the paper. The most notable is that items have an
`originRight` as well as an `origin` property, which improves performance when
many concurrent inserts happen after the same character.
At it heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
At its heart, Yjs is a list CRDT. Everything is squeezed into a list in order to
reuse the CRDT resolution algorithm:
- Arrays are easy - they're lists of arbitrary items.

View File

@@ -198,7 +198,7 @@ const ydoc = new Y.Doc()
// this allows you to instantly get the (cached) documents data
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc)
idbP.whenSynced.then(() => {
indexeddbProvider.whenSynced.then(() => {
console.log('loaded data from indexed db')
})

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.4.0-0",
"version": "13.4.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "yjs",
"version": "13.4.0-0",
"version": "13.4.3",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",

View File

@@ -47,6 +47,7 @@ export {
findRootTypeKey,
typeListToArraySnapshot,
typeMapGetSnapshot,
createDocFromSnapshot,
iterateDeletedStructs,
applyUpdate,
applyUpdateV2,

View File

@@ -51,6 +51,17 @@ export class ContentString {
splice (offset) {
const right = new ContentString(this.str.slice(offset))
this.str = this.str.slice(0, offset)
// Prevent encoding invalid documents because of splitting of surrogate pairs: https://github.com/yjs/yjs/issues/248
const firstCharCode = this.str.charCodeAt(offset - 1)
if (firstCharCode >= 0xD800 && firstCharCode <= 0xDBFF) {
// Last character of the left split is the start of a surrogate utf16/ucs2 pair.
// We don't support splitting of surrogate pairs because this may lead to invalid documents.
// Replace the invalid character with a unicode replacement character (<28> / U+FFFD)
this.str = this.str.slice(0, offset - 1) + '<27>'
// replace right as well
right.str = '<27>' + right.str.slice(1)
}
return right
}

View File

@@ -26,8 +26,6 @@ import {
} from '../internals.js'
import * as error from 'lib0/error.js'
import * as maplib from 'lib0/map.js'
import * as set from 'lib0/set.js'
import * as binary from 'lib0/binary.js'
/**
@@ -594,7 +592,7 @@ export class Item extends AbstractStruct {
}
this.markDeleted()
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
maplib.setIfUndefined(transaction.changed, parent, set.create).add(this.parentSub)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
this.content.delete(transaction)
}
}

View File

@@ -132,9 +132,9 @@ export class YMap extends AbstractType {
}
/**
* Returns the keys for each element in the YMap Type.
* Returns the values for each element in the YMap Type.
*
* @return {IterableIterator<string>}
* @return {IterableIterator<any>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this._map), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])

View File

@@ -51,7 +51,12 @@ export const addEventHandlerListener = (eventHandler, f) =>
* @function
*/
export const removeEventHandlerListener = (eventHandler, f) => {
eventHandler.l = eventHandler.l.filter(g => f !== g)
const l = eventHandler.l
const len = l.length
eventHandler.l = l.filter(g => f !== g)
if (len === eventHandler.l.length) {
console.error('[yjs] Tried to remove event handler that doesn\'t exist.')
}
}
/**

View File

@@ -51,7 +51,7 @@ export class PermanentUserData {
})
})
})
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(encodedDs)))))
this.dss.set(userDescription, mergeDeleteSets(ds.map(encodedDs => readDeleteSet(new DSDecoderV1(decoding.createDecoder(encodedDs))))))
ids.observe(/** @param {YArrayEvent<any>} event */ event =>
event.changes.added.forEach(item => item.content.getContent().forEach(addClientId))
)

View File

@@ -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
}

View File

@@ -284,6 +284,9 @@ const cleanupTransactions = (transactionCleanups, i) => {
.forEach(event => {
event.currentTarget = type
})
// sort events by path length so that top-level events are fired first.
events
.sort((event1, event2) => event1.path.length - event2.path.length)
// We don't need to check for events.length
// because we know it has at least one element
callEventHandlerListeners(type._dEH, events, transaction)

View File

@@ -123,6 +123,12 @@ const popStackItem = (undoManager, stack, eventType) => {
undoManager.emit('stack-item-popped', [{ stackItem: result, type: eventType }, undoManager])
}
}
transaction.changed.forEach((subProps, type) => {
// destroy search marker if necessary
if (subProps.has(null) && type._searchMarker) {
type._searchMarker.length = 0
}
})
}, undoManager)
return result
}
@@ -151,10 +157,7 @@ export class UndoManager extends Observable {
* @param {AbstractType<any>|Array<AbstractType<any>>} typeScope Accepts either a single type, or an array of types
* @param {UndoManagerOptions} options
*/
constructor (typeScope, { captureTimeout, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
if (captureTimeout == null) {
captureTimeout = 500
}
constructor (typeScope, { captureTimeout = 500, deleteFilter = () => true, trackedOrigins = new Set([null]) } = {}) {
super()
this.scope = typeScope instanceof Array ? typeScope : [typeScope]
this.deleteFilter = deleteFilter

View File

@@ -78,7 +78,7 @@ export class YEvent {
}
/**
* @return {{added:Set<Item>,deleted:Set<Item>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
* @return {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert:Array<any>}|{delete:number}|{retain:number}>}}
*/
get changes () {
let changes = this._changes

View File

@@ -1,4 +1,5 @@
import * as t from 'lib0/testing.js'
import * as promise from 'lib0/promise.js'
import {
contentRefs,
@@ -10,7 +11,11 @@ import {
readContentType,
readContentFormat,
readContentAny,
readContentDoc
readContentDoc,
Doc,
PermanentUserData,
encodeStateAsUpdate,
applyUpdate
} from '../src/internals.js'
/**
@@ -28,3 +33,31 @@ export const testStructReferences = tc => {
t.assert(contentRefs[8] === readContentAny)
t.assert(contentRefs[9] === readContentDoc)
}
/**
* There is some custom encoding/decoding happening in PermanentUserData.
* This is why it landed here.
*
* @param {t.TestCase} tc
*/
export const testPermanentUserData = async tc => {
const ydoc1 = new Doc()
const ydoc2 = new Doc()
const pd1 = new PermanentUserData(ydoc1)
const pd2 = new PermanentUserData(ydoc2)
pd1.setUserMapping(ydoc1, ydoc1.clientID, 'user a')
pd2.setUserMapping(ydoc2, ydoc2.clientID, 'user b')
ydoc1.getText().insert(0, 'xhi')
ydoc1.getText().delete(0, 1)
ydoc2.getText().insert(0, 'hxxi')
ydoc2.getText().delete(1, 2)
await promise.wait(10)
applyUpdate(ydoc2, encodeStateAsUpdate(ydoc1))
applyUpdate(ydoc1, encodeStateAsUpdate(ydoc2))
// now sync a third doc with same name as doc1 and then create PermanentUserData
const ydoc3 = new Doc()
applyUpdate(ydoc3, encodeStateAsUpdate(ydoc1))
const pd3 = new PermanentUserData(ydoc3)
pd3.setUserMapping(ydoc3, ydoc3.clientID, 'user a')
}

View File

@@ -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) {

171
tests/snapshot.tests.js Normal file
View File

@@ -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'])
}

View File

@@ -53,6 +53,28 @@ export const testUndoText = tc => {
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
}
/**
* Test case to fix #241
* @param {t.TestCase} tc
*/
export const testDoubleUndo = tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, '1221')
const manager = new Y.UndoManager(text)
text.insert(2, '3')
text.insert(3, '3')
manager.undo()
manager.undo()
text.insert(2, '3')
t.compareStrings(text.toString(), '12321')
}
/**
* @param {t.TestCase} tc
*/

View File

@@ -204,6 +204,34 @@ export const testInsertAndDeleteEventsForTypes = tc => {
compare(users)
}
/**
* This issue has been reported in https://discuss.yjs.dev/t/order-in-which-events-yielded-by-observedeep-should-be-applied/261/2
*
* Deep observers generate multiple events. When an array added at item at, say, position 0,
* and item 1 changed then the array-add event should fire first so that the change event
* path is correct. A array binding might lead to an inconsistent state otherwise.
*
* @param {t.TestCase} tc
*/
export const testObserveDeepEventOrder = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<any>}
*/
let events = []
array0.observeDeep(e => {
events = e
})
array0.insert(0, [new Y.Map()])
users[0].transact(() => {
array0.get(0).set('a', 'a')
array0.insert(0, [0])
})
for (let i = 1; i < events.length; i++) {
t.assert(events[i - 1].path.length <= events[i].path.length, 'path size increases, fire top-level events first')
}
}
/**
* @param {t.TestCase} tc
*/

View File

@@ -249,6 +249,8 @@ export const testAppendChars = tc => {
t.assert(text0.length === N)
}
const largeDocumentSize = 100000
const id = Y.createID(0, 0)
const c = new Y.ContentString('a')
@@ -256,7 +258,7 @@ const c = new Y.ContentString('a')
* @param {t.TestCase} tc
*/
export const testBestCase = tc => {
const N = 2000000
const N = largeDocumentSize
const items = new Array(N)
t.measureTime('time to create two million items in the best case', () => {
const parent = /** @type {any} */ ({})
@@ -293,7 +295,7 @@ const tryGc = () => {
* @param {t.TestCase} tc
*/
export const testLargeFragmentedDocument = tc => {
const itemsToInsert = 1000000
const itemsToInsert = largeDocumentSize
let update = /** @type {any} */ (null)
;(() => {
const doc1 = new Y.Doc()
@@ -321,6 +323,40 @@ export const testLargeFragmentedDocument = tc => {
})()
}
/**
* Splitting surrogates can lead to invalid encoded documents.
*
* https://github.com/yjs/yjs/issues/248
*
* @param {t.TestCase} tc
*/
export const testSplitSurrogateCharacter = tc => {
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾') // insert surrogate character
// split surrogate, which should not lead to an encoding error
text0.insert(1, 'hi!')
compare(users)
}
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾👾') // insert surrogate character
// partially delete surrogate
text0.delete(1, 2)
compare(users)
}
{
const { users, text0 } = init(tc, { users: 2 })
users[1].disconnect() // disconnecting forces the user to encode the split surrogate
text0.insert(0, '👾👾') // insert surrogate character
// formatting will also split surrogates
text0.format(1, 2, { bold: true })
compare(users)
}
}
// RANDOM TESTS
let charCounter = 0