Compare commits

..

5 Commits
main ... v12

Author SHA1 Message Date
Kevin Jahns
03685ac84a Release 12.3.3 2017-08-04 20:36:43 +02:00
Kevin Jahns
6fa79320a5 disable preferUntransformed 2017-08-04 20:36:07 +02:00
Kevin Jahns
4e3a297efc created doc for the auth method 2017-07-19 19:17:36 +02:00
Kevin Jahns
ef2cbf210b Release 12.3.2 2017-07-19 18:50:53 +02:00
Kevin Jahns
3a1c7a71d4 Provide sender information in checkAuth. Related to #79 2017-07-19 18:50:18 +02:00
86 changed files with 5363 additions and 24728 deletions

12
.flowconfig Normal file
View File

@ -0,0 +1,12 @@
[ignore]
.*/node_modules/.*
.*/dist/.*
.*/build/.*
[include]
./src/
[libs]
./declarations/
[options]

View File

@ -1,31 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm run test
env:
CI: true

15
.gitignore vendored
View File

@ -1,4 +1,15 @@
node_modules
dist
bower_components
build
build_test
.directory
.codio
.settings
.jshintignore
.jshintrc
.validate.json
/y.js
/y.js.map
/y-*
.vscode
docs
jsconfig.json

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "dist"]
path = dist
url = https://github.com/y-js/yjs.git
branch = dist

View File

@ -1,52 +0,0 @@
{
"sourceType": "module",
"tags": {
"allowUnknownTags": true,
"dictionaries": ["jsdoc"]
},
"source": {
"include": ["./src"],
"includePattern": ".js$"
},
"plugins": [
"plugins/markdown"
],
"templates": {
"referenceTitle": "Yjs",
"disableSort": false,
"useCollapsibles": true,
"collapse": true,
"resources": {
"yjs.dev": "Website",
"docs.yjs.dev": "Docs",
"discuss.yjs.dev": "Forum",
"https://gitter.im/Yjs/community": "Chat"
},
"logo": {
"url": "https://yjs.dev/images/logo/yjs-512x512.png",
"width": "162px",
"height": "162px",
"link": "/"
},
"tabNames": {
"api": "API",
"tutorials": "Examples"
},
"footerText": "Shared Editing",
"css": [
"./style.css"
],
"default": {
"staticFiles": {
"include": []
}
}
},
"opts": {
"destination": "./docs/",
"encoding": "utf8",
"private": false,
"recurse": true,
"template": "./node_modules/tui-jsdoc-template"
}
}

View File

@ -1,4 +0,0 @@
{
"default": true,
"no-inline-html": false
}

View File

@ -1,179 +0,0 @@
# Yjs Internals
This document roughly explains how Yjs works internally. There is a complete
walkthrough of the Yjs codebase available as a recording:
https://youtu.be/0l5XgnQ6rB4
The Yjs CRDT algorithm is described in the [YATA
paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types)
from 2016. For an algorithmic view of how it works, the paper is a reasonable
place to start. There are a handful of small improvements implemented in Yjs
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 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.
- Text is a list of characters, optionally punctuated by formatting markers and
embeds for rich text support. Several characters can be wrapped in a single
linked list `Item` (this is also known as the compound representation of
CRDTs). More information about this in [this blog
article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/).
- Maps are lists of entries. The last inserted entry for each key is used, and
all other duplicates for each key are flagged as deleted.
Each client is assigned a unique *clientID* property on first insert. This is a
random 53-bit integer (53 bits because that fits in the javascript safe integer
range \[JavaScript uses IEEE 754 floats\]).
## List items
Each item in a Yjs list is made up of two objects:
- An `Item` (*src/structs/Item.js*). This is used to relate the item to other
adjacent items.
- An object in the `AbstractType` hierarchy (subclasses of
*src/types/AbstractType.js* - eg `YText`). This stores the actual content in
the Yjs document.
The item and type object pair have a 1-1 mapping. The item's `content` field
references the AbstractType object and the AbstractType object's `_item` field
references the item.
Everything inserted in a Yjs document is given a unique ID, formed from a
*ID(clientID, clock)* pair (also known as a [Lamport
Timestamp](https://en.wikipedia.org/wiki/Lamport_timestamp)). The clock counts
up from 0 with the first inserted character or item a client makes. This is
similar to automerge's operation IDs, but note that the clock is only
incremented by inserts. Deletes are handled in a very different way (see
below).
If a run of characters is inserted into a document (eg `"abc"`), the clock will
be incremented for each character (eg 3 times here). But Yjs will only add a
single `Item` into the list. This has no effect on the core CRDT algorithm, but
the optimization dramatically decreases the number of javascript objects
created during normal text editing. This optimization only applies if the
characters share the same clientID, they're inserted in order, and all
characters have either been deleted or all characters are not deleted. The item
will be split if the run is interrupted for any reason (eg a character in the
middle of the run is deleted).
When an item is created, it stores a reference to the IDs of the preceding and
succeeding item. These are stored in the item's `origin` and `originRight`
fields, respectively. These are used when peers concurrently insert at the same
location in a document. Though quite rare in practice, Yjs needs to make sure
the list items always resolve to the same order on all peers. The actual logic
is relatively simple - its only a couple dozen lines of code and it lives in
the `Item#integrate()` method. The YATA paper has much more detail on this
algorithm.
### Item Storage
The items themselves are stored in two data structures and a cache:
- The items are stored in a tree of doubly-linked lists in *document order*.
Each item has `left` and `right` properties linking to its siblings in the
document. Items also have a `parent` property to reference their parent in the
document tree (null at the root). (And you can access an item's children, if
any, through `item.content`).
- All items are referenced in *insertion order* inside the struct store
(*src/utils/StructStore.js*). This references the list of items inserted by
for each client, in chronological order. This is used to find an item in the
tree with a given ID (using a binary search). It is also used to efficiently
gather the operations a peer is missing during sync (more on this below).
When a local insert happens, Yjs needs to map the insert position in the
document (eg position 1000) to an ID. With just the linked list, this would
require a slow O(n) linear scan of the list. But when editing a document, most
inserts are either at the same position as the last insert, or nearby. To
improve performance, Yjs stores a cache of the 80 most recently looked up
insert positions in the document. This is consulted and updated when a position
is looked up to improve performance in the average case. The cache is updated
using a heuristic that is still changing (currently, it is updated when a new
position significantly diverges from existing markers in the cache). Internally
this is referred to as the skip list / fast search marker.
### Deletions
Deletions in Yjs are treated very differently from insertions. Insertions are
implemented as a sequential operation based CRDT, but deletions are treated as
a simpler state based CRDT.
When an item has been deleted by any peer, at any point in history, it is
flagged as deleted on the item. (Internally Yjs uses the `info` bitfield.) Yjs
does not record metadata about a deletion:
- No data is kept on *when* an item was deleted, or which user deleted it.
- The struct store does not contain deletion records
- The clientID's clock is not incremented
If garbage collection is enabled in Yjs, when an object is deleted its content
is discarded. If a deleted object contains children (eg a field is deleted in
an object), the content is replaced with a `GC` object (*src/structs/GC.js*).
This is a very lightweight structure - it only stores the length of the removed
content.
Yjs has some special logic to share which content in a document has been
deleted:
- When a delete happens, as well as marking the item, the deleted IDs are
listed locally within the transaction. (See below for more information about
transactions.) When a transaction has been committed locally, the set of
deleted items is appended to a transaction's update message.
- A snapshot (a marked point in time in the Yjs history) is specified using
both the set of (clientID, clock) pairs *and* the set of all deleted item
IDs. The deleted set is O(n), but because deletions usually happen in runs,
this data set is usually tiny in practice. (The real world editing trace from
the B4 benchmark document contains 182k inserts and 77k deleted characters. The
deleted set size in a snapshot is only 4.5Kb).
## Transactions
All updates in Yjs happen within a *transaction*. (Defined in
*src/utils/Transaction.js*.)
The transaction collects a set of updates to the Yjs document to be applied on
remote peers atomically. Once a transaction has been committed locally, it
generates a compressed *update message* which is broadcast to synchronized
remote peers to notify them of the local change. The update message contains:
- The set of newly inserted items
- The set of items deleted within the transaction.
## Network protocol
The network protocol is not really a part of Yjs. There are a few relevant
concepts that can be used to create a custom network protocol:
* `update`: The Yjs document can be encoded to an *update* object that can be
parsed to reconstruct the document. Also every change on the document fires
an incremental document update that allows clients to sync with each other.
The update object is a Uint8Array that efficiently encodes `Item` objects and
the delete set.
* `state vector`: A state vector defines the known state of each user (a set of
tuples `(client, clock)`). This object is also efficiently encoded as a
Uint8Array.
The client can ask a remote client for missing document updates by sending
their state vector (often referred to as *sync step 1*). The remote peer can
compute the missing `Item` objects using the `clocks` of the respective clients
and compute a minimal update message that reflects all missing updates (sync
step 2).
An implementation of the syncing process is in
[y-protocols](https://github.com/yjs/y-protocols).
## Snapshots
A snapshot can be used to restore an old document state. It is a `state vector`
\+ `delete set`. A client can restore an old document state by iterating through
the sequence CRDT and ignoring all Items that have an `id.clock >
stateVector[id.client].clock`. Instead of using `item.deleted` the client will
use the delete set to find out if an item was deleted or not.
It is not recommended to restore an old document state using snapshots,
although that would certainly be possible. Instead, the old state should be
computed by iterating through the newest state and using the additional
information from the state vector.

View File

@ -1,7 +1,7 @@
The MIT License (MIT)
Copyright (c) 2023
- Kevin Jahns <kevin.jahns@protonmail.com>.
Copyright (c) 2014
- Kevin Jahns <kevin.jahns@rwth-aachen.de>.
- Chair of Computer Science 5 (Databases & Information Systems), RWTH Aachen University, Germany
Permission is hereby granted, free of charge, to any person obtaining a copy

1559
README.md

File diff suppressed because it is too large Load Diff

72
declarations/Structs.js Normal file
View File

@ -0,0 +1,72 @@
/* @flow */
type UserId = string
type Id = [UserId, number|string]
/*
type Struct = {
id: Id,
left?: Id,
right?: Id,
target?: Id,
struct: 'Insert' | 'Delete'
}*/
type Struct = Insertion | Deletion
type Operation = Struct
type Insertion = {
id: Id,
left: ?Id,
origin: ?Id,
right: ?Id,
parent: Id,
parentSub: ?Id,
opContent: ?Id,
content: ?any,
struct: 'Insert'
}
type Deletion = {
target: Id,
struct: 'Delete'
}
type MapStruct = {
id: Id,
type: TypeNames,
map: any
}
type ListStruct = {
id: Id,
type: TypeNames,
start: Id,
end: Id
}
type MessageSyncStep1 = {
type: 'sync step 1',
deleteSet: any,
stateSet: any
}
type MessageSyncStep2 = {
type: 'sync step 2',
os: Array<Operation>,
deleteSet: any,
stateSet: any
}
type MessageUpdate = {
type: 'update',
ops: Array<Operation>
}
type MessageSyncDone = {
type: 'sync done'
}
type Message = MessageSyncStep1 | MessageSyncStep2 | MessageUpdate | MessageSyncDone

0
declarations/Type.js Normal file
View File

34
declarations/Y.js Normal file
View File

@ -0,0 +1,34 @@
/* @flow */
type YGlobal = {
utils: Object,
Struct: any,
AbstractDatabase: any,
AbstractConnector: any,
Transaction: any
}
type YConfig = {
db: Object,
connector: Object,
root: Object
}
type TypeName = 'array' | 'map' | 'text'
declare var YConcurrency_TestingMode : boolean
type Transaction<A> = Generator<any, A, any>
type SyncRole = 'master' | 'slave'
declare class Store {
find: (id:Id) => Transaction<any>;
put: (n:any) => Transaction<void>;
delete: (id:Id) => Transaction<void>;
findWithLowerBound: (start:Id) => Transaction<any>;
findWithUpperBound: (end:Id) => Transaction<any>;
findNext: (id:Id) => Transaction<any>;
findPrev: (id:Id) => Transaction<any>;
iterate: (t:any,start:?Id,end:?Id,gen:any) => Transaction<any>;
}

1
dist Submodule

@ -0,0 +1 @@
Subproject commit 8739fd3a9c671c738fe0f31f1dc03193da776223

View File

@ -1,142 +0,0 @@
{
"version": "v1.0.0",
"entity": {
"type": "group",
"role": "steward",
"name": "Kevin Jahns",
"email": "kevin.jahns@protonmail.com",
"phone": "",
"description": "OSS Developer",
"webpageUrl": {
"url": "https://github.com/yjs"
}
},
"projects": [
{
"guid": "yjs",
"name": "Yjs",
"description": "A library for building collaborative applications. #p2p #local-first #CRDT Funding this project will also enable me to maintain the other Yjs-related technologies.",
"webpageUrl": {
"url": "https://github.com/yjs/yjs"
},
"repositoryUrl": {
"url": "https://github.com/yjs/yjs"
},
"licenses": [
"spdx:MIT"
],
"tags": [
"collaboration",
"p2p",
"CRDT",
"rich-text",
"real-time"
]
},
{
"guid": "Titanic",
"name": "Y/Titanic",
"description": "A provider for syncing millions of docs efficiently with other peers. This will become the foundation for building real local-first apps with Yjs.",
"webpageUrl": {
"url": "https://github.com/yjs/titanic",
"wellKnown": "https://github.com/yjs/titanic/blob/main/.well-known/funding-manifest-urls"
},
"repositoryUrl": {
"url": "https://github.com/yjs/titanic",
"wellKnown": "https://github.com/yjs/titanic/blob/main/.well-known/funding-manifest-urls"
},
"licenses": [
"spdx:MIT"
],
"tags": [
"privacy",
"collaboration",
"p2p",
"CRDT",
"rich-text",
"real-time",
"web-development"
]
}
],
"funding": {
"channels": [
{
"guid": "github-sponsors",
"type": "payment-provider",
"address": "",
"description": "For funding of the Yjs project"
},
{
"guid": "y-collective",
"type": "payment-provider",
"address": "https://opencollective.com/y-collective",
"description": "For funding the Y-CRDT - the Rust implementation of Yjs and other listed projects."
}
],
"plans": [
{
"guid": "supporter",
"status": "active",
"name": "Supporter",
"description": "",
"amount": 0,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors",
"y-collective"
]
},
{
"guid": "titanic-funding",
"status": "active",
"name": "Titanic Funding",
"description": "Fund the next generation of local-first providers.",
"amount": 30000,
"currency": "USD",
"frequency": "one-time",
"channels": [
"github-sponsors"
]
},
{
"guid": "bronze-sponsor",
"status": "active",
"name": "Bronze Sponsor",
"description": "This is the recommended plan for companies that use Yjs.",
"amount": 500,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors"
]
},
{
"guid": "silver-sponsor",
"status": "active",
"name": "Silver Sponsor",
"description": "This is the recommended plan for large/successfull companies that use Yjs.",
"amount": 1000,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors"
]
},
{
"guid": "gold-sponsor",
"status": "active",
"name": "Gold Sponsor",
"description": "This is the recommended plan for successful companies that build their entire product around Yjs-related technologies.",
"amount": 3000,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors"
]
}
],
"history": null
}
}

214
gulpfile.helper.js Normal file
View File

@ -0,0 +1,214 @@
var $ = require('gulp-load-plugins')()
var minimist = require('minimist')
var browserify = require('browserify')
var source = require('vinyl-source-stream')
var buffer = require('vinyl-buffer')
module.exports = function (gulp, helperOptions) {
var runSequence = require('run-sequence').use(gulp)
var options = minimist(process.argv.slice(2), {
string: ['modulename', 'export', 'name', 'port', 'testfiles', 'es6'],
default: {
moduleName: helperOptions.moduleName,
targetName: helperOptions.targetName,
export: 'ignore',
port: '8888',
testfiles: '**/*.spec.js',
es6: false,
browserify: helperOptions.browserify != null ? helperOptions.browserify : false,
includeRuntime: helperOptions.includeRuntime || false,
debug: false
}
})
if (options.es6 !== false) {
options.es6 = true
}
var files = {
dist: helperOptions.entry,
specs: helperOptions.specs,
src: './src/**/*.js'
}
if (options.includeRuntime) {
files.distEs5 = ['node_modules/regenerator/runtime.js', files.dist]
} else {
files.distEs5 = [files.dist]
}
var header = require('gulp-header')
var banner = ['/**',
' * <%= pkg.name %> - <%= pkg.description %>',
' * @version v<%= pkg.version %>',
' * @link <%= pkg.homepage %>',
' * @license <%= pkg.license %>',
' */',
''].join('\n')
gulp.task('dist:es5', function () {
var babelOptions = {
presets: ['es2015']
}
return (browserify({
entries: files.distEs5,
debug: true,
standalone: options.moduleName
}).transform('babelify', babelOptions)
.bundle()
.pipe(source(options.targetName))
.pipe(buffer())
.pipe($.sourcemaps.init({loadMaps: true}))
.pipe($.if(!options.debug, $.uglify().on('error', function (e) {
console.log('\x07', e.message, JSON.stringify(e)); return this.end()
})))
.pipe(header(banner, { pkg: require('./package.json') }))
.pipe($.sourcemaps.write('.'))
.pipe(gulp.dest('./dist/')))
})
gulp.task('dist:es6', function () {
return (browserify({
entries: files.dist,
debug: true,
standalone: options.moduleName
}).bundle()
.pipe(source(options.targetName))
.pipe(buffer())
.pipe($.sourcemaps.init({loadMaps: true}))
// .pipe($.uglify()) -- generators not yet supported see #448
.pipe($.rename({
extname: '.es6'
}))
.pipe(header(banner, { pkg: require('./package.json') }))
.pipe($.sourcemaps.write('.'))
.pipe(gulp.dest('./dist/')))
})
gulp.task('dist', ['dist:es6', 'dist:es5'])
gulp.task('watch:dist', function (cb) {
options.debug = true
gulp.src(['./README.md'])
.pipe($.watch('./README.md'))
.pipe(gulp.dest('./dist/'))
runSequence('dist', function () {
gulp.watch(files.src.concat('./README.md'), ['dist'])
cb()
})
})
gulp.task('dev:node', ['test'], function () {
gulp.watch(files.src, ['test'])
})
gulp.task('spec-build', function () {
var browserify = require('browserify')
var source = require('vinyl-source-stream')
var buffer = require('vinyl-buffer')
return browserify({
entries: files.specs, // .concat(files.distEs5),
debug: true
})// .transform('babelify', { presets: ['es2015'] })
.bundle()
.pipe(source('specs.js'))
.pipe(buffer())
// .pipe($.sourcemaps.init({loadMaps: true}))
// .pipe($.sourcemaps.write('.'))
.pipe(gulp.dest('./build/'))
})
gulp.task('dev:browser', ['spec-build'], function () {
gulp.watch(files.src, ['spec-build'])
return gulp.src('./build/specs.js')
.pipe($.jasmineBrowser.specRunner())
.pipe($.jasmineBrowser.server({port: options.port}))
})
gulp.task('test', function () {
return gulp.src(files.specs)
.pipe($.jasmine({
verbose: true,
includeStuckTrace: true
}))
})
gulp.task('updateSubmodule', function () {
return gulp.src('./package.json', {read: false})
.pipe($.shell([
'git submodule update --init',
'cd dist && git pull origin dist'
]))
})
gulp.task('bump', function (cb) {
gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
.pipe($.prompt.prompt({
type: 'checkbox',
name: 'bump',
message: 'What type of bump would you like to do?',
choices: ['patch', 'minor', 'major']
}, function (res) {
if (res.bump.length === 0) {
console.info('You have to select a bump type. Now I\'m going to use "patch" as bump type..')
}
var bumptype = res.bump[0]
if (bumptype === 'major') {
runSequence('bump_major', cb)
} else if (bumptype === 'minor') {
runSequence('bump_minor', cb)
} else {
runSequence('bump_patch', cb)
}
}))
})
gulp.task('bump_patch', function () {
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
.pipe($.bump({type: 'patch'}))
.pipe(gulp.dest('./'))
})
gulp.task('bump_minor', function () {
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
.pipe($.bump({type: 'minor'}))
.pipe(gulp.dest('./'))
})
gulp.task('bump_major', function () {
return gulp.src(['./package.json', './bower.json', './dist/bower.json'], {base: '.'})
.pipe($.bump({type: 'major'}))
.pipe(gulp.dest('./'))
})
gulp.task('publish_commits', function () {
return gulp.src('./package.json')
.pipe($.prompt.confirm({
message: 'Are you sure you want to publish this release?',
default: false
}))
.pipe($.shell([
'cp README.md dist',
'standard',
'echo "Deploying version <%= getVersion(file.path) %>"',
'git pull',
'cd ./dist/ && git add -A',
'cd ./dist/ && git commit -am "Deploy <%= getVersion(file.path) %>" -n',
'cd ./dist/ && git push origin HEAD:dist',
'cd ./dist/ && git tag -a v<%= getVersion(file.path) %> -m "Release <%= getVersion(file.path) %>"',
'cd ./dist/ && git push origin --tags',
'git commit -am "Release <%= getVersion(file.path) %>" -n',
'git push',
'npm publish',
'echo Finished'
], {
templateData: {
getVersion: function () {
return JSON.parse(String.fromCharCode.apply(null, this.file._contents)).version
}
}
}))
})
gulp.task('publish', function (cb) {
/* TODO: include 'test',*/
runSequence('updateSubmodule', 'bump', 'dist', 'publish_commits', cb)
})
}

104
gulpfile.js Normal file
View File

@ -0,0 +1,104 @@
/* eslint-env node */
/** Gulp Commands
gulp command*
[--export ModuleType]
[--name ModuleName]
[--testport TestPort]
[--testfiles TestFiles]
Module name (ModuleName):
Compile this to "y.js" (default)
Supported module types (ModuleType):
- amd
- amdStrict
- common
- commonStrict
- ignore (default)
- system
- umd
- umdStrict
Test port (TestPort):
Serve the specs on port 8888 (default)
Test files (TestFiles):
Specify which specs to use!
Commands:
- build:deploy
Build this library for deployment (es6->es5, minified)
- dev:browser
Watch the ./src directory.
Builds the library on changes.
Starts an http-server and serves the test suite on http://127.0.0.1:8888.
- dev:node
Watch the ./src directory.
Builds and specs the library on changes.
Usefull to run with node-inspector.
`node-debug $(which gulp) dev:node
- test:
Test this library
*/
var gulp = require('gulp')
var $ = require('gulp-load-plugins')()
var runSequence = require('run-sequence').use(gulp)
require('./gulpfile.helper.js')(gulp, {
polyfills: [],
entry: './src/y.js',
targetName: 'y.js',
moduleName: 'Y',
includeRuntime: true,
specs: [
'./src/Database.spec.js',
'../y-array/src/Array.spec.js',
'../y-map/src/Map.spec.js'
]
})
gulp.task('dev:examples', ['watch:dist'], function () {
// watch all distfiles and copy them to bower_components
var distfiles = ['./dist/*.{js,es6}', './dist/*.{js,es6}.map', '../y-*/dist/*.{js,es6}', '../y-*/dist/*.{js,es6}.map']
gulp.src(distfiles)
.pipe($.watch(distfiles))
.pipe($.rename(function (path) {
var dir = path.dirname.split(/[\\\/]/)[0]
console.log(JSON.stringify(path))
path.dirname = dir === '.' ? 'yjs' : dir
}))
.pipe(gulp.dest('./dist/Examples/bower_components/'))
return $.serve('dist/Examples/')()
})
gulp.task('default', ['updateSubmodule'], function (cb) {
gulp.src('package.json')
.pipe($.prompt.prompt({
type: 'checkbox',
name: 'tasks',
message: 'Which tasks would you like to run?',
choices: [
'test Test this project',
'dev:examples Serve the examples directory in ./dist/',
'dev:browser Watch files & serve the testsuite for the browser',
'dev:nodejs Watch filse & test this project with nodejs',
'bump Bump the current state of the project',
'publish Publish this project. Creates a github tag',
'dist Build the distribution files'
]
}, function (res) {
var tasks = res.tasks.map(function (task) {
return task.split(' ')[0]
})
if (tasks.length > 0) {
console.info('gulp ' + tasks.join(' '))
runSequence(tasks, cb)
} else {
console.info('Ok, .. goodbye')
}
}))
})

4987
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,99 +1,82 @@
{
"name": "yjs",
"version": "13.6.24",
"description": "Shared Editing Library",
"main": "./dist/yjs.cjs",
"module": "./dist/yjs.mjs",
"types": "./dist/src/index.d.ts",
"type": "module",
"sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"version": "12.3.3",
"description": "A framework for real-time p2p shared editing on any data",
"main": "./src/y.js",
"scripts": {
"clean": "rm -rf dist docs",
"test": "npm run dist && NODE_ENV=development node ./dist/tests.cjs --repetition-time 50",
"test-extensive": "npm run lint && npm run dist && node ./dist/tests.cjs --production --repetition-time 10000",
"dist": "npm run clean && rollup -c && tsc",
"watch": "rollup -wc",
"lint": "markdownlint README.md && standard && tsc",
"docs": "rm -rf docs; jsdoc --configure ./.jsdoc.json --verbose --readme ./README.md --package ./package.json || true",
"serve-docs": "npm run docs && http-server ./docs/",
"preversion": "npm run lint && PRODUCTION=1 npm run dist && npm run docs && node ./dist/tests.cjs --repetition-time 1000 && test -e dist/src/index.d.ts && test -e dist/yjs.cjs && test -e dist/yjs.cjs",
"debug": "concurrently 'http-server -o test.html' 'npm run watch'",
"trace-deopt": "clear && rollup -c && node --trace-deopt dist/test.cjs",
"trace-opt": "clear && rollup -c && node --trace-opt dist/test.cjs"
"test": "node --harmony ./node_modules/.bin/gulp test",
"lint": "./node_modules/.bin/standard"
},
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"module": "./dist/yjs.mjs",
"import": "./dist/yjs.mjs",
"require": "./dist/yjs.cjs"
},
"./src/index.js": "./src/index.js",
"./tests/testHelper.js": "./tests/testHelper.js",
"./testHelper": "./dist/testHelper.mjs",
"./package.json": "./package.json"
},
"files": [
"dist/yjs.*",
"dist/src",
"src",
"tests/testHelper.js",
"dist/testHelper.mjs",
"sponsor-y.js"
"pre-commit": [
"lint",
"test"
],
"dictionaries": {
"test": "tests"
},
"standard": {
"parser": "babel-eslint",
"ignore": [
"/dist",
"/node_modules",
"/docs"
"build/**",
"dist/**",
"declarations/**",
"./y.js",
"./y.js.map"
]
},
"repository": {
"type": "git",
"url": "https://github.com/yjs/yjs.git"
"url": "https://github.com/y-js/yjs.git"
},
"keywords": [
"Yjs",
"CRDT",
"offline",
"offline-first",
"shared-editing",
"concurrency",
"collaboration"
"OT",
"Collaboration",
"Synchronization",
"ShareJS",
"Coweb",
"Concurrency"
],
"author": "Kevin Jahns",
"email": "kevin.jahns@protonmail.com",
"email": "kevin.jahns@rwth-aachen.de",
"license": "MIT",
"bugs": {
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "https://docs.yjs.dev",
"dependencies": {
"lib0": "^0.2.99"
"url": "https://github.com/y-js/yjs/issues"
},
"homepage": "http://y-js.org",
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@types/node": "^18.15.5",
"concurrently": "^3.6.1",
"http-server": "^0.12.3",
"jsdoc": "^3.6.7",
"markdownlint-cli": "^0.41.0",
"rollup": "^3.20.0",
"standard": "^16.0.4",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^4.9.5",
"y-protocols": "^1.0.5"
"babel-eslint": "^5.0.0-beta6",
"babel-plugin-transform-runtime": "^6.1.18",
"babel-preset-es2015": "^6.1.18",
"babelify": "^7.2.0",
"browserify": "^12.0.1",
"eslint": "^1.10.2",
"gulp": "^3.9.0",
"gulp-bump": "^1.0.0",
"gulp-concat": "^2.6.0",
"gulp-filter": "^3.0.1",
"gulp-git": "^1.6.0",
"gulp-header": "^1.8.8",
"gulp-if": "^2.0.0",
"gulp-jasmine": "^2.0.1",
"gulp-jasmine-browser": "^0.2.3",
"gulp-load-plugins": "^1.0.0",
"gulp-prompt": "^0.1.2",
"gulp-rename": "^1.2.2",
"gulp-serve": "^1.2.0",
"gulp-shell": "^0.5.1",
"gulp-sourcemaps": "^1.5.2",
"gulp-tag-version": "^1.3.0",
"gulp-uglify": "^2.0.0",
"gulp-util": "^3.0.6",
"gulp-watch": "^4.3.5",
"minimist": "^1.2.0",
"pre-commit": "^1.1.1",
"regenerator": "^0.8.42",
"run-sequence": "^1.1.4",
"seedrandom": "^2.4.2",
"standard": "^5.2.2",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0"
},
"engines": {
"npm": ">=8.0.0",
"node": ">=16.0.0"
"dependencies": {
"debug": "^2.6.3"
}
}

View File

@ -1,106 +0,0 @@
import nodeResolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
const localImports = process.env.LOCALIMPORTS
const customModules = new Set([
'y-websocket',
'y-codemirror',
'y-ace',
'y-textarea',
'y-quill',
'y-dom',
'y-prosemirror'
])
/**
* @type {Set<any>}
*/
const customLibModules = new Set([
'lib0',
'y-protocols'
])
const debugResolve = {
resolveId (importee) {
if (importee === 'yjs') {
return `${process.cwd()}/src/index.js`
}
if (localImports) {
if (customModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}/src/${importee}.js`
}
if (customLibModules.has(importee.split('/')[0])) {
return `${process.cwd()}/../${importee}`
}
}
return null
}
}
export default [{
input: './src/index.js',
output: {
name: 'Y',
file: 'dist/yjs.cjs',
format: 'cjs',
sourcemap: true
},
external: id => /^lib0\//.test(id)
}, {
input: './src/index.js',
output: {
name: 'Y',
file: 'dist/yjs.mjs',
format: 'esm',
sourcemap: true
},
external: id => /^lib0\//.test(id)
}, {
input: './tests/testHelper.js',
output: {
name: 'Y',
file: 'dist/testHelper.mjs',
format: 'esm',
sourcemap: true
},
external: id => /^lib0\//.test(id) || id === 'yjs',
plugins: [{
resolveId (importee) {
if (importee === '../src/index.js') {
return 'yjs'
}
return null
}
}]
}, {
input: './tests/index.js',
output: {
name: 'test',
file: 'dist/tests.js',
format: 'iife',
sourcemap: true
},
plugins: [
debugResolve,
nodeResolve({
mainFields: ['browser', 'module', 'main']
}),
commonjs()
]
}, {
input: './tests/index.js',
output: {
name: 'test',
file: 'dist/tests.cjs',
format: 'cjs',
sourcemap: true
},
plugins: [
debugResolve,
nodeResolve({
mainFields: ['node', 'module', 'main'],
exportConditions: ['node', 'module', 'import', 'default']
}),
commonjs()
],
external: id => /^lib0\//.test(id)
}]

485
src/Connector.js Normal file
View File

@ -0,0 +1,485 @@
function canRead (auth) { return auth === 'read' || auth === 'write' }
function canWrite (auth) { return auth === 'write' }
module.exports = function (Y/* :any */) {
class AbstractConnector {
/* ::
y: YConfig;
role: SyncRole;
connections: Object;
isSynced: boolean;
userEventListeners: Array<Function>;
whenSyncedListeners: Array<Function>;
currentSyncTarget: ?UserId;
syncingClients: Array<UserId>;
forwardToSyncingClients: boolean;
debug: boolean;
syncStep2: Promise;
userId: UserId;
send: Function;
broadcast: Function;
broadcastOpBuffer: Array<Operation>;
protocolVersion: number;
*/
/*
opts contains the following information:
role : String Role of this client ("master" or "slave")
userId : String Uniquely defines the user.
debug: Boolean Whether to print debug messages (optional)
*/
constructor (y, opts) {
this.y = y
if (opts == null) {
opts = {}
}
// Prefer to receive untransformed operations. This does only work if
// this client receives operations from only one other client.
// In particular, this does not work with y-webrtc.
// It will work with y-websockets-client
if (opts.role == null || opts.role === 'master') {
this.role = 'master'
} else if (opts.role === 'slave') {
this.role = 'slave'
} else {
throw new Error("Role must be either 'master' or 'slave'!")
}
this.log = Y.debug('y:connector')
this.logMessage = Y.debug('y:connector-message')
this.y.db.forwardAppliedOperations = opts.forwardAppliedOperations || false
this.role = opts.role
this.connections = {}
this.isSynced = false
this.userEventListeners = []
this.whenSyncedListeners = []
this.currentSyncTarget = null
this.syncingClients = []
this.forwardToSyncingClients = opts.forwardToSyncingClients !== false
this.debug = opts.debug === true
this.syncStep2 = Promise.resolve()
this.broadcastOpBuffer = []
this.protocolVersion = 11
this.authInfo = opts.auth || null
this.checkAuth = opts.checkAuth || function () { return Promise.resolve('write') } // default is everyone has write access
if (opts.generateUserId === true) {
this.setUserId(Y.utils.generateGuid())
}
}
resetAuth (auth) {
if (this.authInfo !== auth) {
this.authInfo = auth
this.broadcast({
type: 'auth',
auth: this.authInfo
})
}
}
reconnect () {
this.log('reconnecting..')
return this.y.db.startGarbageCollector()
}
disconnect () {
this.log('discronnecting..')
this.connections = {}
this.isSynced = false
this.currentSyncTarget = null
this.syncingClients = []
this.whenSyncedListeners = []
this.y.db.stopGarbageCollector()
return this.y.db.whenTransactionsFinished()
}
repair () {
this.log('Repairing the state of Yjs. This can happen if messages get lost, and Yjs detects that something is wrong. If this happens often, please report an issue here: https://github.com/y-js/yjs/issues')
for (var name in this.connections) {
this.connections[name].isSynced = false
}
this.isSynced = false
this.currentSyncTarget = null
this.findNextSyncTarget()
}
setUserId (userId) {
if (this.userId == null) {
this.log('Set userId to "%s"', userId)
this.userId = userId
return this.y.db.setUserId(userId)
} else {
return null
}
}
onUserEvent (f) {
this.userEventListeners.push(f)
}
removeUserEventListener (f) {
this.userEventListeners = this.userEventListeners.filter(g => { f !== g })
}
userLeft (user) {
if (this.connections[user] != null) {
this.log('User left: %s', user)
delete this.connections[user]
if (user === this.currentSyncTarget) {
this.currentSyncTarget = null
this.findNextSyncTarget()
}
this.syncingClients = this.syncingClients.filter(function (cli) {
return cli !== user
})
for (var f of this.userEventListeners) {
f({
action: 'userLeft',
user: user
})
}
}
}
userJoined (user, role) {
if (role == null) {
throw new Error('You must specify the role of the joined user!')
}
if (this.connections[user] != null) {
throw new Error('This user already joined!')
}
this.log('User joined: %s', user)
this.connections[user] = {
isSynced: false,
role: role
}
for (var f of this.userEventListeners) {
f({
action: 'userJoined',
user: user,
role: role
})
}
if (this.currentSyncTarget == null) {
this.findNextSyncTarget()
}
}
// Execute a function _when_ we are connected.
// If not connected, wait until connected
whenSynced (f) {
if (this.isSynced) {
f()
} else {
this.whenSyncedListeners.push(f)
}
}
findNextSyncTarget () {
if (this.currentSyncTarget != null) {
return // "The current sync has not finished!"
}
var syncUser = null
for (var uid in this.connections) {
if (!this.connections[uid].isSynced) {
syncUser = uid
break
}
}
var conn = this
if (syncUser != null) {
this.currentSyncTarget = syncUser
this.y.db.requestTransaction(function *() {
var stateSet = yield* this.getStateSet()
var deleteSet = yield* this.getDeleteSet()
var answer = {
type: 'sync step 1',
stateSet: stateSet,
deleteSet: deleteSet,
protocolVersion: conn.protocolVersion,
auth: conn.authInfo
}
conn.send(syncUser, answer)
})
} else {
if (!conn.isSynced) {
this.y.db.requestTransaction(function *() {
if (!conn.isSynced) {
// it is crucial that isSynced is set at the time garbageCollectAfterSync is called
conn.isSynced = true
yield* this.garbageCollectAfterSync()
// call whensynced listeners
for (var f of conn.whenSyncedListeners) {
f()
}
conn.whenSyncedListeners = []
}
})
}
}
}
send (uid, message) {
this.log('Send \'%s\' to %s', message.type, uid)
this.logMessage('Message: %j', message)
}
broadcast (message) {
this.log('Broadcast \'%s\'', message.type)
this.logMessage('Message: %j', message)
}
/*
Buffer operations, and broadcast them when ready.
*/
broadcastOps (ops) {
ops = ops.map(function (op) {
return Y.Struct[op.struct].encode(op)
})
var self = this
function broadcastOperations () {
if (self.broadcastOpBuffer.length > 0) {
self.broadcast({
type: 'update',
ops: self.broadcastOpBuffer
})
self.broadcastOpBuffer = []
}
}
if (this.broadcastOpBuffer.length === 0) {
this.broadcastOpBuffer = ops
if (this.y.db.transactionInProgress) {
this.y.db.whenTransactionsFinished().then(broadcastOperations)
} else {
setTimeout(broadcastOperations, 0)
}
} else {
this.broadcastOpBuffer = this.broadcastOpBuffer.concat(ops)
}
}
/*
You received a raw message, and you know that it is intended for Yjs. Then call this function.
*/
receiveMessage (sender/* :UserId */, message/* :Message */) {
if (sender === this.userId) {
return Promise.resolve()
}
this.log('Receive \'%s\' from %s', message.type, sender)
this.logMessage('Message: %j', message)
if (message.protocolVersion != null && message.protocolVersion !== this.protocolVersion) {
this.log(
`You tried to sync with a yjs instance that has a different protocol version
(You: ${this.protocolVersion}, Client: ${message.protocolVersion}).
The sync was stopped. You need to upgrade your dependencies (especially Yjs & the Connector)!
`)
this.send(sender, {
type: 'sync stop',
protocolVersion: this.protocolVersion
})
return Promise.reject('Incompatible protocol version')
}
if (message.auth != null && this.connections[sender] != null) {
// authenticate using auth in message
var auth = this.checkAuth(message.auth, this.y, sender)
this.connections[sender].auth = auth
auth.then(auth => {
for (var f of this.userEventListeners) {
f({
action: 'userAuthenticated',
user: sender,
auth: auth
})
}
})
} else if (this.connections[sender] != null && this.connections[sender].auth == null) {
// authenticate without otherwise
this.connections[sender].auth = this.checkAuth(null, this.y, sender)
}
if (this.connections[sender] != null && this.connections[sender].auth != null) {
return this.connections[sender].auth.then((auth) => {
if (message.type === 'sync step 1' && canRead(auth)) {
let conn = this
let m = message
this.y.db.requestTransaction(function *() {
var currentStateSet = yield* this.getStateSet()
if (canWrite(auth)) {
yield* this.applyDeleteSet(m.deleteSet)
}
var ds = yield* this.getDeleteSet()
var answer = {
type: 'sync step 2',
stateSet: currentStateSet,
deleteSet: ds,
protocolVersion: this.protocolVersion,
auth: this.authInfo
}
answer.os = yield* this.getOperations(m.stateSet)
conn.send(sender, answer)
if (this.forwardToSyncingClients) {
conn.syncingClients.push(sender)
setTimeout(function () {
conn.syncingClients = conn.syncingClients.filter(function (cli) {
return cli !== sender
})
conn.send(sender, {
type: 'sync done'
})
}, 5000) // TODO: conn.syncingClientDuration)
} else {
conn.send(sender, {
type: 'sync done'
})
}
})
} else if (message.type === 'sync step 2' && canWrite(auth)) {
var db = this.y.db
var defer = {}
defer.promise = new Promise(function (resolve) {
defer.resolve = resolve
})
this.syncStep2 = defer.promise
let m /* :MessageSyncStep2 */ = message
db.requestTransaction(function * () {
yield* this.applyDeleteSet(m.deleteSet)
if (m.osUntransformed != null) {
yield* this.applyOperationsUntransformed(m.osUntransformed, m.stateSet)
} else {
this.store.apply(m.os)
}
/*
* This just sends the complete hb after some time
* Mostly for debugging..
*
db.requestTransaction(function * () {
var ops = yield* this.getOperations(m.stateSet)
if (ops.length > 0) {
if (!broadcastHB) { // TODO: consider to broadcast here..
conn.send(sender, {
type: 'update',
ops: ops
})
} else {
// broadcast only once!
conn.broadcastOps(ops)
}
}
})
*/
defer.resolve()
})
} else if (message.type === 'sync done') {
var self = this
this.syncStep2.then(function () {
self._setSyncedWith(sender)
})
} else if (message.type === 'update' && canWrite(auth)) {
if (this.forwardToSyncingClients) {
for (var client of this.syncingClients) {
this.send(client, message)
}
}
if (this.y.db.forwardAppliedOperations) {
var delops = message.ops.filter(function (o) {
return o.struct === 'Delete'
})
if (delops.length > 0) {
this.broadcastOps(delops)
}
}
this.y.db.apply(message.ops)
}
})
} else {
return Promise.reject('Unable to deliver message')
}
}
_setSyncedWith (user) {
var conn = this.connections[user]
if (conn != null) {
conn.isSynced = true
}
if (user === this.currentSyncTarget) {
this.currentSyncTarget = null
this.findNextSyncTarget()
}
}
/*
Currently, the HB encodes operations as JSON. For the moment I want to keep it
that way. Maybe we support encoding in the HB as XML in the future, but for now I don't want
too much overhead. Y is very likely to get changed a lot in the future
Because we don't want to encode JSON as string (with character escaping, wich makes it pretty much unreadable)
we encode the JSON as XML.
When the HB support encoding as XML, the format should look pretty much like this.
does not support primitive values as array elements
expects an ltx (less than xml) object
*/
parseMessageFromXml (m/* :any */) {
function parseArray (node) {
for (var n of node.children) {
if (n.getAttribute('isArray') === 'true') {
return parseArray(n)
} else {
return parseObject(n)
}
}
}
function parseObject (node/* :any */) {
var json = {}
for (var attrName in node.attrs) {
var value = node.attrs[attrName]
var int = parseInt(value, 10)
if (isNaN(int) || ('' + int) !== value) {
json[attrName] = value
} else {
json[attrName] = int
}
}
for (var n/* :any */ in node.children) {
var name = n.name
if (n.getAttribute('isArray') === 'true') {
json[name] = parseArray(n)
} else {
json[name] = parseObject(n)
}
}
return json
}
parseObject(m)
}
/*
encode message in xml
we use string because Strophe only accepts an "xml-string"..
So {a:4,b:{c:5}} will look like
<y a="4">
<b c="5"></b>
</y>
m - ltx element
json - Object
*/
encodeMessageToXml (msg, obj) {
// attributes is optional
function encodeObject (m, json) {
for (var name in json) {
var value = json[name]
if (name == null) {
// nop
} else if (value.constructor === Object) {
encodeObject(m.c(name), value)
} else if (value.constructor === Array) {
encodeArray(m.c(name), value)
} else {
m.setAttribute(name, value)
}
}
}
function encodeArray (m, array) {
m.setAttribute('isArray', 'true')
for (var e of array) {
if (e.constructor === Object) {
encodeObject(m.c('array-element'), e)
} else {
encodeArray(m.c('array-element'), e)
}
}
}
if (obj.constructor === Object) {
encodeObject(msg.c('y', { xmlns: 'http://y.ninja/connector-stanza' }), obj)
} else if (obj.constructor === Array) {
encodeArray(msg.c('y', { xmlns: 'http://y.ninja/connector-stanza' }), obj)
} else {
throw new Error("I can't encode this json!")
}
}
}
Y.AbstractConnector = AbstractConnector
}

178
src/Connectors/Test.js Normal file
View File

@ -0,0 +1,178 @@
/* global getRandom, async */
'use strict'
module.exports = function (Y) {
var globalRoom = {
users: {},
buffers: {},
removeUser: function (user) {
for (var i in this.users) {
this.users[i].userLeft(user)
}
delete this.users[user]
delete this.buffers[user]
},
addUser: function (connector) {
this.users[connector.userId] = connector
this.buffers[connector.userId] = {}
for (var uname in this.users) {
if (uname !== connector.userId) {
var u = this.users[uname]
u.userJoined(connector.userId, 'master')
connector.userJoined(u.userId, 'master')
}
}
},
whenTransactionsFinished: function () {
var self = this
return new Promise(function (resolve, reject) {
// The connector first has to send the messages to the db.
// Wait for the checkAuth-function to resolve
// The test lib only has a simple checkAuth function: `() => Promise.resolve()`
// Just add a function to the event-queue, in order to wait for the event.
// TODO: this may be buggy in test applications (but it isn't be for real-life apps)
setTimeout(function () {
var ps = []
for (var name in self.users) {
ps.push(self.users[name].y.db.whenTransactionsFinished())
}
Promise.all(ps).then(resolve, reject)
}, 10)
})
},
flushOne: function flushOne () {
var bufs = []
for (var receiver in globalRoom.buffers) {
let buff = globalRoom.buffers[receiver]
var push = false
for (let sender in buff) {
if (buff[sender].length > 0) {
push = true
break
}
}
if (push) {
bufs.push(receiver)
}
}
if (bufs.length > 0) {
var userId = getRandom(bufs)
let buff = globalRoom.buffers[userId]
let sender = getRandom(Object.keys(buff))
var m = buff[sender].shift()
if (buff[sender].length === 0) {
delete buff[sender]
}
var user = globalRoom.users[userId]
return user.receiveMessage(m[0], m[1]).then(function () {
return user.y.db.whenTransactionsFinished()
}, function () {})
} else {
return false
}
},
flushAll: function () {
return new Promise(function (resolve) {
// flushes may result in more created operations,
// flush until there is nothing more to flush
function nextFlush () {
var c = globalRoom.flushOne()
if (c) {
while (c) {
c = globalRoom.flushOne()
}
globalRoom.whenTransactionsFinished().then(nextFlush)
} else {
c = globalRoom.flushOne()
if (c) {
c.then(function () {
globalRoom.whenTransactionsFinished().then(nextFlush)
})
} else {
resolve()
}
}
}
globalRoom.whenTransactionsFinished().then(nextFlush)
})
}
}
Y.utils.globalRoom = globalRoom
var userIdCounter = 0
class Test extends Y.AbstractConnector {
constructor (y, options) {
if (options === undefined) {
throw new Error('Options must not be undefined!')
}
options.role = 'master'
options.forwardToSyncingClients = false
super(y, options)
this.setUserId((userIdCounter++) + '').then(() => {
globalRoom.addUser(this)
})
this.globalRoom = globalRoom
this.syncingClientDuration = 0
}
receiveMessage (sender, m) {
return super.receiveMessage(sender, JSON.parse(JSON.stringify(m)))
}
send (userId, message) {
var buffer = globalRoom.buffers[userId]
if (buffer != null) {
if (buffer[this.userId] == null) {
buffer[this.userId] = []
}
buffer[this.userId].push(JSON.parse(JSON.stringify([this.userId, message])))
}
}
broadcast (message) {
for (var key in globalRoom.buffers) {
var buff = globalRoom.buffers[key]
if (buff[this.userId] == null) {
buff[this.userId] = []
}
buff[this.userId].push(JSON.parse(JSON.stringify([this.userId, message])))
}
}
isDisconnected () {
return globalRoom.users[this.userId] == null
}
reconnect () {
if (this.isDisconnected()) {
globalRoom.addUser(this)
super.reconnect()
}
return Y.utils.globalRoom.flushAll()
}
disconnect () {
var waitForMe = Promise.resolve()
if (!this.isDisconnected()) {
globalRoom.removeUser(this.userId)
waitForMe = super.disconnect()
}
var self = this
return waitForMe.then(function () {
return self.y.db.whenTransactionsFinished()
})
}
flush () {
var self = this
return async(function * () {
var buff = globalRoom.buffers[self.userId]
while (Object.keys(buff).length > 0) {
var sender = getRandom(Object.keys(buff))
var m = buff[sender].shift()
if (buff[sender].length === 0) {
delete buff[sender]
}
yield this.receiveMessage(m[0], m[1])
}
yield self.whenTransactionsFinished()
})
}
}
Y.Test = Test
}

604
src/Database.js Normal file
View File

@ -0,0 +1,604 @@
/* @flow */
'use strict'
module.exports = function (Y /* :any */) {
/*
Partial definition of an OperationStore.
TODO: name it Database, operation store only holds operations.
A database definition must alse define the following methods:
* logTable() (optional)
- show relevant information information in a table
* requestTransaction(makeGen)
- request a transaction
* destroy()
- destroy the database
*/
class AbstractDatabase {
/* ::
y: YConfig;
forwardAppliedOperations: boolean;
listenersById: Object;
listenersByIdExecuteNow: Array<Object>;
listenersByIdRequestPending: boolean;
initializedTypes: Object;
whenUserIdSetListener: ?Function;
waitingTransactions: Array<Transaction>;
transactionInProgress: boolean;
executeOrder: Array<Object>;
gc1: Array<Struct>;
gc2: Array<Struct>;
gcTimeout: number;
gcInterval: any;
garbageCollect: Function;
executeOrder: Array<any>; // for debugging only
userId: UserId;
opClock: number;
transactionsFinished: ?{promise: Promise, resolve: any};
transact: (x: ?Generator) => any;
*/
constructor (y, opts) {
this.y = y
this.dbOpts = opts
var os = this
this.userId = null
var resolve
this.userIdPromise = new Promise(function (r) {
resolve = r
})
this.userIdPromise.resolve = resolve
// whether to broadcast all applied operations (insert & delete hook)
this.forwardAppliedOperations = false
// E.g. this.listenersById[id] : Array<Listener>
this.listenersById = {}
// Execute the next time a transaction is requested
this.listenersByIdExecuteNow = []
// A transaction is requested
this.listenersByIdRequestPending = false
/* To make things more clear, the following naming conventions:
* ls : we put this.listenersById on ls
* l : Array<Listener>
* id : Id (can't use as property name)
* sid : String (converted from id via JSON.stringify
so we can use it as a property name)
Always remember to first overwrite
a property before you iterate over it!
*/
// TODO: Use ES7 Weak Maps. This way types that are no longer user,
// wont be kept in memory.
this.initializedTypes = {}
this.waitingTransactions = []
this.transactionInProgress = false
this.transactionIsFlushed = false
if (typeof YConcurrency_TestingMode !== 'undefined') {
this.executeOrder = []
}
this.gc1 = [] // first stage
this.gc2 = [] // second stage -> after that, remove the op
function garbageCollect () {
return os.whenTransactionsFinished().then(function () {
if (os.gc1.length > 0 || os.gc2.length > 0) {
if (!os.y.connector.isSynced) {
console.warn('gc should be empty when not synced!')
}
return new Promise((resolve) => {
os.requestTransaction(function * () {
if (os.y.connector != null && os.y.connector.isSynced) {
for (var i = 0; i < os.gc2.length; i++) {
var oid = os.gc2[i]
yield* this.garbageCollectOperation(oid)
}
os.gc2 = os.gc1
os.gc1 = []
}
// TODO: Use setInterval here instead (when garbageCollect is called several times there will be several timeouts..)
if (os.gcTimeout > 0) {
os.gcInterval = setTimeout(garbageCollect, os.gcTimeout)
}
resolve()
})
})
} else {
// TODO: see above
if (os.gcTimeout > 0) {
os.gcInterval = setTimeout(garbageCollect, os.gcTimeout)
}
return Promise.resolve()
}
})
}
this.garbageCollect = garbageCollect
this.startGarbageCollector()
this.repairCheckInterval = !opts.repairCheckInterval ? 6000 : opts.repairCheckInterval
this.opsReceivedTimestamp = new Date()
this.startRepairCheck()
}
startGarbageCollector () {
this.gc = this.dbOpts.gc == null || this.dbOpts.gc
if (this.gc) {
this.gcTimeout = !this.dbOpts.gcTimeout ? 50000 : this.dbOpts.gcTimeout
} else {
this.gcTimeout = -1
}
if (this.gcTimeout > 0) {
this.garbageCollect()
}
}
startRepairCheck () {
var os = this
if (this.repairCheckInterval > 0) {
this.repairCheckIntervalHandler = setInterval(function repairOnMissingOperations () {
/*
Case 1. No ops have been received in a while (new Date() - os.opsReceivedTimestamp > os.repairCheckInterval)
- 1.1 os.listenersById is empty. Then the state was correct the whole time. -> Nothing to do (nor to update)
- 1.2 os.listenersById is not empty.
* Then the state was incorrect for at least {os.repairCheckInterval} seconds.
* -> Remove everything in os.listenersById and sync again (connector.repair())
Case 2. An op has been received in the last {os.repairCheckInterval } seconds.
It is not yet necessary to check for faulty behavior. Everything can still resolve itself. Wait for more messages.
If nothing was received for a while and os.listenersById is still not emty, we are in case 1.2
-> Do nothing
Baseline here is: we really only have to catch case 1.2..
*/
if (
new Date() - os.opsReceivedTimestamp > os.repairCheckInterval &&
Object.keys(os.listenersById).length > 0 // os.listenersById is not empty
) {
// haven't received operations for over {os.repairCheckInterval} seconds, resend state vector
os.listenersById = {}
os.opsReceivedTimestamp = new Date() // update so you don't send repair several times in a row
os.y.connector.repair()
}
}, this.repairCheckInterval)
}
}
stopRepairCheck () {
clearInterval(this.repairCheckIntervalHandler)
}
queueGarbageCollector (id) {
if (this.y.connector.isSynced && this.gc) {
this.gc1.push(id)
}
}
emptyGarbageCollector () {
return new Promise(resolve => {
var check = () => {
if (this.gc1.length > 0 || this.gc2.length > 0) {
this.garbageCollect().then(check)
} else {
resolve()
}
}
setTimeout(check, 0)
})
}
addToDebug () {
if (typeof YConcurrency_TestingMode !== 'undefined') {
var command /* :string */ = Array.prototype.map.call(arguments, function (s) {
if (typeof s === 'string') {
return s
} else {
return JSON.stringify(s)
}
}).join('').replace(/"/g, "'").replace(/,/g, ', ').replace(/:/g, ': ')
this.executeOrder.push(command)
}
}
getDebugData () {
console.log(this.executeOrder.join('\n'))
}
stopGarbageCollector () {
var self = this
this.gc = false
this.gcTimeout = -1
return new Promise(function (resolve) {
self.requestTransaction(function * () {
var ungc /* :Array<Struct> */ = self.gc1.concat(self.gc2)
self.gc1 = []
self.gc2 = []
for (var i = 0; i < ungc.length; i++) {
var op = yield* this.getOperation(ungc[i])
if (op != null) {
delete op.gc
yield* this.setOperation(op)
}
}
resolve()
})
})
}
/*
Try to add to GC.
TODO: rename this function
Rulez:
* Only gc if this user is online & gc turned on
* The most left element in a list must not be gc'd.
=> There is at least one element in the list
returns true iff op was added to GC
*/
* addToGarbageCollector (op, left) {
if (
op.gc == null &&
op.deleted === true &&
this.store.gc &&
this.store.y.connector.isSynced
) {
var gc = false
if (left != null && left.deleted === true) {
gc = true
} else if (op.content != null && op.content.length > 1) {
op = yield* this.getInsertionCleanStart([op.id[0], op.id[1] + 1])
gc = true
}
if (gc) {
op.gc = true
yield* this.setOperation(op)
this.store.queueGarbageCollector(op.id)
return true
}
}
return false
}
removeFromGarbageCollector (op) {
function filter (o) {
return !Y.utils.compareIds(o, op.id)
}
this.gc1 = this.gc1.filter(filter)
this.gc2 = this.gc2.filter(filter)
delete op.gc
}
destroyTypes () {
for (var key in this.initializedTypes) {
var type = this.initializedTypes[key]
if (type._destroy != null) {
type._destroy()
} else {
console.error('The type you included does not provide destroy functionality, it will remain in memory (updating your packages will help).')
}
}
}
* destroy () {
clearInterval(this.gcInterval)
this.gcInterval = null
this.stopRepairCheck()
}
setUserId (userId) {
if (!this.userIdPromise.inProgress) {
this.userIdPromise.inProgress = true
var self = this
self.requestTransaction(function * () {
self.userId = userId
var state = yield* this.getState(userId)
self.opClock = state.clock
self.userIdPromise.resolve(userId)
})
}
return this.userIdPromise
}
whenUserIdSet (f) {
this.userIdPromise.then(f)
}
getNextOpId (numberOfIds) {
if (numberOfIds == null) {
throw new Error('getNextOpId expects the number of created ids to create!')
} else if (this.userId == null) {
throw new Error('OperationStore not yet initialized!')
} else {
var id = [this.userId, this.opClock]
this.opClock += numberOfIds
return id
}
}
/*
Apply a list of operations.
* we save a timestamp, because we received new operations that could resolve ops in this.listenersById (see this.startRepairCheck)
* get a transaction
* check whether all Struct.*.requiredOps are in the OS
* check if it is an expected op (otherwise wait for it)
* check if was deleted, apply a delete operation after op was applied
*/
apply (ops) {
this.opsReceivedTimestamp = new Date()
for (var i = 0; i < ops.length; i++) {
var o = ops[i]
if (o.id == null || o.id[0] !== this.y.connector.userId) {
var required = Y.Struct[o.struct].requiredOps(o)
if (o.requires != null) {
required = required.concat(o.requires)
}
this.whenOperationsExist(required, o)
}
}
}
/*
op is executed as soon as every operation requested is available.
Note that Transaction can (and should) buffer requests.
*/
whenOperationsExist (ids, op) {
if (ids.length > 0) {
let listener = {
op: op,
missing: ids.length
}
for (let i = 0; i < ids.length; i++) {
let id = ids[i]
let sid = JSON.stringify(id)
let l = this.listenersById[sid]
if (l == null) {
l = []
this.listenersById[sid] = l
}
l.push(listener)
}
} else {
this.listenersByIdExecuteNow.push({
op: op
})
}
if (this.listenersByIdRequestPending) {
return
}
this.listenersByIdRequestPending = true
var store = this
this.requestTransaction(function * () {
var exeNow = store.listenersByIdExecuteNow
store.listenersByIdExecuteNow = []
var ls = store.listenersById
store.listenersById = {}
store.listenersByIdRequestPending = false
for (let key = 0; key < exeNow.length; key++) {
let o = exeNow[key].op
yield* store.tryExecute.call(this, o)
}
for (var sid in ls) {
var l = ls[sid]
var id = JSON.parse(sid)
var op
if (typeof id[1] === 'string') {
op = yield* this.getOperation(id)
} else {
op = yield* this.getInsertion(id)
}
if (op == null) {
store.listenersById[sid] = l
} else {
for (let i = 0; i < l.length; i++) {
let listener = l[i]
let o = listener.op
if (--listener.missing === 0) {
yield* store.tryExecute.call(this, o)
}
}
}
}
})
}
/*
Actually execute an operation, when all expected operations are available.
*/
/* :: // TODO: this belongs somehow to transaction
store: Object;
getOperation: any;
isGarbageCollected: any;
addOperation: any;
whenOperationsExist: any;
*/
* tryExecute (op) {
this.store.addToDebug('yield* this.store.tryExecute.call(this, ', JSON.stringify(op), ')')
if (op.struct === 'Delete') {
yield* Y.Struct.Delete.execute.call(this, op)
// this is now called in Transaction.deleteOperation!
// yield* this.store.operationAdded(this, op)
} else {
// check if this op was defined
var defined = yield* this.getInsertion(op.id)
while (defined != null && defined.content != null) {
// check if this op has a longer content in the case it is defined
if (defined.id[1] + defined.content.length < op.id[1] + op.content.length) {
var overlapSize = defined.content.length - (op.id[1] - defined.id[1])
op.content.splice(0, overlapSize)
op.id = [op.id[0], op.id[1] + overlapSize]
op.left = Y.utils.getLastId(defined)
op.origin = op.left
defined = yield* this.getOperation(op.id) // getOperation suffices here
} else {
break
}
}
if (defined == null) {
var opid = op.id
var isGarbageCollected = yield* this.isGarbageCollected(opid)
if (!isGarbageCollected) {
// TODO: reduce number of get / put calls for op ..
yield* Y.Struct[op.struct].execute.call(this, op)
yield* this.addOperation(op)
yield* this.store.operationAdded(this, op)
// operationAdded can change op..
op = yield* this.getOperation(opid)
// if insertion, try to combine with left
yield* this.tryCombineWithLeft(op)
}
}
}
}
/*
* Called by a transaction when an operation is added.
* This function is especially important for y-indexeddb, where several instances may share a single database.
* Every time an operation is created by one instance, it is send to all other instances and operationAdded is called
*
* If it's not a Delete operation:
* * Checks if another operation is executable (listenersById)
* * Update state, if possible
*
* Always:
* * Call type
*/
* operationAdded (transaction, op) {
if (op.struct === 'Delete') {
var type = this.initializedTypes[JSON.stringify(op.targetParent)]
if (type != null) {
yield* type._changed(transaction, op)
}
} else {
// increase SS
yield* transaction.updateState(op.id[0])
var opLen = op.content != null ? op.content.length : 1
for (let i = 0; i < opLen; i++) {
// notify whenOperation listeners (by id)
var sid = JSON.stringify([op.id[0], op.id[1] + i])
var l = this.listenersById[sid]
delete this.listenersById[sid]
if (l != null) {
for (var key in l) {
var listener = l[key]
if (--listener.missing === 0) {
this.whenOperationsExist([], listener.op)
}
}
}
}
var t = this.initializedTypes[JSON.stringify(op.parent)]
// if parent is deleted, mark as gc'd and return
if (op.parent != null) {
var parentIsDeleted = yield* transaction.isDeleted(op.parent)
if (parentIsDeleted) {
yield* transaction.deleteList(op.id)
return
}
}
// notify parent, if it was instanciated as a custom type
if (t != null) {
let o = Y.utils.copyOperation(op)
yield* t._changed(transaction, o)
}
if (!op.deleted) {
// Delete if DS says this is actually deleted
var len = op.content != null ? op.content.length : 1
var startId = op.id // You must not use op.id in the following loop, because op will change when deleted
// TODO: !! console.log('TODO: change this before commiting')
for (let i = 0; i < len; i++) {
var id = [startId[0], startId[1] + i]
var opIsDeleted = yield* transaction.isDeleted(id)
if (opIsDeleted) {
var delop = {
struct: 'Delete',
target: id
}
yield* this.tryExecute.call(transaction, delop)
}
}
}
}
}
whenTransactionsFinished () {
if (this.transactionInProgress) {
if (this.transactionsFinished == null) {
var resolve
var promise = new Promise(function (r) {
resolve = r
})
this.transactionsFinished = {
resolve: resolve,
promise: promise
}
}
return this.transactionsFinished.promise
} else {
return Promise.resolve()
}
}
// Check if there is another transaction request.
// * the last transaction is always a flush :)
getNextRequest () {
if (this.waitingTransactions.length === 0) {
if (this.transactionIsFlushed) {
this.transactionInProgress = false
this.transactionIsFlushed = false
if (this.transactionsFinished != null) {
this.transactionsFinished.resolve()
this.transactionsFinished = null
}
return null
} else {
this.transactionIsFlushed = true
return function * () {
yield* this.flush()
}
}
} else {
this.transactionIsFlushed = false
return this.waitingTransactions.shift()
}
}
requestTransaction (makeGen/* :any */, callImmediately) {
this.waitingTransactions.push(makeGen)
if (!this.transactionInProgress) {
this.transactionInProgress = true
setTimeout(() => {
this.transact(this.getNextRequest())
}, 0)
}
}
/*
Get a created/initialized type.
*/
getType (id) {
return this.initializedTypes[JSON.stringify(id)]
}
/*
Init type. This is called when a remote operation is retrieved, and transformed to a type
TODO: delete type from store.initializedTypes[id] when corresponding id was deleted!
*/
* initType (id, args) {
var sid = JSON.stringify(id)
var t = this.store.initializedTypes[sid]
if (t == null) {
var op/* :MapStruct | ListStruct */ = yield* this.getOperation(id)
if (op != null) {
t = yield* Y[op.type].typeDefinition.initType.call(this, this.store, op, args)
this.store.initializedTypes[sid] = t
}
}
return t
}
/*
Create type. This is called when the local user creates a type (which is a synchronous action)
*/
createType (typedefinition, id) {
var structname = typedefinition[0].struct
id = id || this.getNextOpId(1)
var op = Y.Struct[structname].create(id)
op.type = typedefinition[0].name
this.requestTransaction(function * () {
if (op.id[0] === '_') {
yield* this.setOperation(op)
} else {
yield* this.applyCreatedOperations([op])
}
})
var t = Y[op.type].typeDefinition.createType(this, op, typedefinition[1])
this.initializedTypes[JSON.stringify(op.id)] = t
return t
}
}
Y.AbstractDatabase = AbstractDatabase
}

354
src/Database.spec.js Normal file
View File

@ -0,0 +1,354 @@
/* global async, databases, describe, beforeEach, afterEach */
/* eslint-env browser,jasmine,console */
'use strict'
var Y = require('./SpecHelper.js')
for (let database of databases) {
describe(`Database (${database})`, function () {
var store
describe('DeleteStore', function () {
describe('Basic', function () {
beforeEach(function () {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
})
afterEach(function (done) {
store.requestTransaction(function * () {
yield* this.store.destroy()
done()
})
})
it('Deleted operation is deleted', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['u1', 10], 1)
expect(yield* this.isDeleted(['u1', 10])).toBeTruthy()
expect(yield* this.getDeleteSet()).toEqual({'u1': [[10, 1, false]]})
done()
})
}))
it('Deleted operation extends other deleted operation', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['u1', 10], 1)
yield* this.markDeleted(['u1', 11], 1)
expect(yield* this.isDeleted(['u1', 10])).toBeTruthy()
expect(yield* this.isDeleted(['u1', 11])).toBeTruthy()
expect(yield* this.getDeleteSet()).toEqual({'u1': [[10, 2, false]]})
done()
})
}))
it('Deleted operation extends other deleted operation', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['0', 3], 1)
yield* this.markDeleted(['0', 4], 1)
yield* this.markDeleted(['0', 2], 1)
expect(yield* this.getDeleteSet()).toEqual({'0': [[2, 3, false]]})
done()
})
}))
it('Debug #1', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['166', 0], 1)
yield* this.markDeleted(['166', 2], 1)
yield* this.markDeleted(['166', 0], 1)
yield* this.markDeleted(['166', 2], 1)
yield* this.markGarbageCollected(['166', 2], 1)
yield* this.markDeleted(['166', 1], 1)
yield* this.markDeleted(['166', 3], 1)
yield* this.markGarbageCollected(['166', 3], 1)
yield* this.markDeleted(['166', 0], 1)
expect(yield* this.getDeleteSet()).toEqual({'166': [[0, 2, false], [2, 2, true]]})
done()
})
}))
it('Debug #2', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['293', 0], 1)
yield* this.markDeleted(['291', 2], 1)
yield* this.markDeleted(['291', 2], 1)
yield* this.markGarbageCollected(['293', 0], 1)
yield* this.markDeleted(['293', 1], 1)
yield* this.markGarbageCollected(['291', 2], 1)
expect(yield* this.getDeleteSet()).toEqual({'291': [[2, 1, true]], '293': [[0, 1, true], [1, 1, false]]})
done()
})
}))
it('Debug #3', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['581', 0], 1)
yield* this.markDeleted(['581', 1], 1)
yield* this.markDeleted(['580', 0], 1)
yield* this.markDeleted(['580', 0], 1)
yield* this.markGarbageCollected(['581', 0], 1)
yield* this.markDeleted(['581', 2], 1)
yield* this.markDeleted(['580', 1], 1)
yield* this.markDeleted(['580', 2], 1)
yield* this.markDeleted(['580', 1], 1)
yield* this.markDeleted(['580', 2], 1)
yield* this.markGarbageCollected(['581', 2], 1)
yield* this.markGarbageCollected(['581', 1], 1)
yield* this.markGarbageCollected(['580', 1], 1)
expect(yield* this.getDeleteSet()).toEqual({'580': [[0, 1, false], [1, 1, true], [2, 1, false]], '581': [[0, 3, true]]})
done()
})
}))
it('Debug #4', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['544', 0], 1)
yield* this.markDeleted(['543', 2], 1)
yield* this.markDeleted(['544', 0], 1)
yield* this.markDeleted(['543', 2], 1)
yield* this.markGarbageCollected(['544', 0], 1)
yield* this.markDeleted(['545', 1], 1)
yield* this.markDeleted(['543', 4], 1)
yield* this.markDeleted(['543', 3], 1)
yield* this.markDeleted(['544', 1], 1)
yield* this.markDeleted(['544', 2], 1)
yield* this.markDeleted(['544', 1], 1)
yield* this.markDeleted(['544', 2], 1)
yield* this.markGarbageCollected(['543', 2], 1)
yield* this.markGarbageCollected(['543', 4], 1)
yield* this.markGarbageCollected(['544', 2], 1)
yield* this.markGarbageCollected(['543', 3], 1)
expect(yield* this.getDeleteSet()).toEqual({'543': [[2, 3, true]], '544': [[0, 1, true], [1, 1, false], [2, 1, true]], '545': [[1, 1, false]]})
done()
})
}))
it('Debug #5', async(function * (done) {
store.requestTransaction(function * () {
yield* this.applyDeleteSet({'16': [[1, 2, false]], '17': [[0, 1, true], [1, 3, false]]})
expect(yield* this.getDeleteSet()).toEqual({'16': [[1, 2, false]], '17': [[0, 1, true], [1, 3, false]]})
yield* this.applyDeleteSet({'16': [[1, 2, false]], '17': [[0, 4, true]]})
expect(yield* this.getDeleteSet()).toEqual({'16': [[1, 2, false]], '17': [[0, 4, true]]})
done()
})
}))
it('Debug #6', async(function * (done) {
store.requestTransaction(function * () {
yield* this.applyDeleteSet({'40': [[0, 3, false]]})
expect(yield* this.getDeleteSet()).toEqual({'40': [[0, 3, false]]})
yield* this.applyDeleteSet({'39': [[2, 2, false]], '40': [[0, 1, true], [1, 2, false]], '41': [[2, 1, false]]})
expect(yield* this.getDeleteSet()).toEqual({'39': [[2, 2, false]], '40': [[0, 1, true], [1, 2, false]], '41': [[2, 1, false]]})
done()
})
}))
it('Debug #7', async(function * (done) {
store.requestTransaction(function * () {
yield* this.markDeleted(['9', 2], 1)
yield* this.markDeleted(['11', 2], 1)
yield* this.markDeleted(['11', 4], 1)
yield* this.markDeleted(['11', 1], 1)
yield* this.markDeleted(['9', 4], 1)
yield* this.markDeleted(['10', 0], 1)
yield* this.markGarbageCollected(['11', 2], 1)
yield* this.markDeleted(['11', 2], 1)
yield* this.markGarbageCollected(['11', 3], 1)
yield* this.markDeleted(['11', 3], 1)
yield* this.markDeleted(['11', 3], 1)
yield* this.markDeleted(['9', 4], 1)
yield* this.markDeleted(['10', 0], 1)
yield* this.markGarbageCollected(['11', 1], 1)
yield* this.markDeleted(['11', 1], 1)
expect(yield* this.getDeleteSet()).toEqual({'9': [[2, 1, false], [4, 1, false]], '10': [[0, 1, false]], '11': [[1, 3, true], [4, 1, false]]})
done()
})
}))
})
})
describe('OperationStore', function () {
describe('Basic Tests', function () {
beforeEach(function () {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
})
afterEach(function (done) {
store.requestTransaction(function * () {
yield* this.store.destroy()
done()
})
})
it('debug #1', function (done) {
store.requestTransaction(function * () {
yield* this.os.put({id: [2]})
yield* this.os.put({id: [0]})
yield* this.os.delete([2])
yield* this.os.put({id: [1]})
expect(yield* this.os.find([0])).toBeTruthy()
expect(yield* this.os.find([1])).toBeTruthy()
expect(yield* this.os.find([2])).toBeFalsy()
done()
})
})
it('can add&retrieve 5 elements', function (done) {
store.requestTransaction(function * () {
yield* this.os.put({val: 'four', id: [4]})
yield* this.os.put({val: 'one', id: [1]})
yield* this.os.put({val: 'three', id: [3]})
yield* this.os.put({val: 'two', id: [2]})
yield* this.os.put({val: 'five', id: [5]})
expect((yield* this.os.find([1])).val).toEqual('one')
expect((yield* this.os.find([2])).val).toEqual('two')
expect((yield* this.os.find([3])).val).toEqual('three')
expect((yield* this.os.find([4])).val).toEqual('four')
expect((yield* this.os.find([5])).val).toEqual('five')
done()
})
})
it('5 elements do not exist anymore after deleting them', function (done) {
store.requestTransaction(function * () {
yield* this.os.put({val: 'four', id: [4]})
yield* this.os.put({val: 'one', id: [1]})
yield* this.os.put({val: 'three', id: [3]})
yield* this.os.put({val: 'two', id: [2]})
yield* this.os.put({val: 'five', id: [5]})
yield* this.os.delete([4])
expect(yield* this.os.find([4])).not.toBeTruthy()
yield* this.os.delete([3])
expect(yield* this.os.find([3])).not.toBeTruthy()
yield* this.os.delete([2])
expect(yield* this.os.find([2])).not.toBeTruthy()
yield* this.os.delete([1])
expect(yield* this.os.find([1])).not.toBeTruthy()
yield* this.os.delete([5])
expect(yield* this.os.find([5])).not.toBeTruthy()
done()
})
})
})
var numberOfOSTests = 1000
describe(`Random Tests - after adding&deleting (0.8/0.2) ${numberOfOSTests} times`, function () {
var elements = []
beforeAll(function (done) {
store = new Y[database](null, {
gcTimeout: -1,
namespace: 'testing'
})
store.requestTransaction(function * () {
for (var i = 0; i < numberOfOSTests; i++) {
var r = Math.random()
if (r < 0.8) {
var obj = [Math.floor(Math.random() * numberOfOSTests * 10000)]
if (!(yield* this.os.find(obj))) {
elements.push(obj)
yield* this.os.put({id: obj})
}
} else if (elements.length > 0) {
var elemid = Math.floor(Math.random() * elements.length)
var elem = elements[elemid]
elements = elements.filter(function (e) {
return !Y.utils.compareIds(e, elem)
})
yield* this.os.delete(elem)
}
}
done()
})
})
afterAll(function (done) {
store.requestTransaction(function * () {
yield* this.store.destroy()
done()
})
})
it('can find every object', function (done) {
store.requestTransaction(function * () {
for (var id of elements) {
expect((yield* this.os.find(id)).id).toEqual(id)
}
done()
})
})
it('can find every object with lower bound search', function (done) {
store.requestTransaction(function * () {
for (var id of elements) {
var e = yield* this.os.findWithLowerBound(id)
expect(e.id).toEqual(id)
}
done()
})
})
it('iterating over a tree with lower bound yields the right amount of results', function (done) {
var lowerBound = elements[Math.floor(Math.random() * elements.length)]
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(lowerBound, e) || Y.utils.compareIds(e, lowerBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield* this.os.iterate(this, lowerBound, null, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree without bounds yield the right amount of results', function (done) {
var lowerBound = null
var expectedResults = elements.filter(function (e, pos) {
return elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield* this.os.iterate(this, lowerBound, null, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree with upper bound yields the right amount of results', function (done) {
var upperBound = elements[Math.floor(Math.random() * elements.length)]
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(e, upperBound) || Y.utils.compareIds(e, upperBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield* this.os.iterate(this, null, upperBound, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
it('iterating over a tree with upper and lower bounds yield the right amount of results', function (done) {
var b1 = elements[Math.floor(Math.random() * elements.length)]
var b2 = elements[Math.floor(Math.random() * elements.length)]
var upperBound, lowerBound
if (Y.utils.smaller(b1, b2)) {
lowerBound = b1
upperBound = b2
} else {
lowerBound = b2
upperBound = b1
}
var expectedResults = elements.filter(function (e, pos) {
return (Y.utils.smaller(lowerBound, e) || Y.utils.compareIds(e, lowerBound)) &&
(Y.utils.smaller(e, upperBound) || Y.utils.compareIds(e, upperBound)) && elements.indexOf(e) === pos
}).length
var actualResults = 0
store.requestTransaction(function * () {
yield* this.os.iterate(this, lowerBound, upperBound, function * (val) {
expect(val).toBeDefined()
actualResults++
})
expect(expectedResults).toEqual(actualResults)
done()
})
})
})
})
})
}

12
src/Notes.md Normal file
View File

@ -0,0 +1,12 @@
# Notes
### Terminology
* DB: DataBase that holds all the information of the shared object. It is devided into the OS, DS, and SS. This can be a persistent database or an in-memory database. Depending on the type of database, it could make sense to store OS, DS, and SS in different tables, or maybe different databases.
* OS: OperationStore holds all the operations. An operation is a js object with a fixed number of name fields.
* DS: DeleteStore holds the information about which operations are deleted and which operations were garbage collected (no longer available in the OS).
* SS: StateSet holds the current state of the OS. SS.getState(username) refers to the amount of operations that were received by that respective user.
* Op: Operation defines an action on a shared type. But it is also the format in which we store the model of a type. This is why it is also called a Struct/Structure.
* Type and Structure: We crearly distinguish between type and structure. Short explanation: A type (e.g. Strings, Numbers) have a number of functions that you can apply on them. (+) is well defined on both of them. They are *modeled* by a structure - the functions really change the structure of a type. Types can be implemented differently but still provide the same functionality. In Yjs, almost all types are realized as a doubly linked list (on which Yjs can provide eventual convergence)
*

404
src/SpecHelper.js Normal file
View File

@ -0,0 +1,404 @@
/* eslint-env browser, jasmine */
/*
This is just a compilation of functions that help to test this library!
*/
// When testing, you store everything on the global object. We call it g
var Y = require('./y.js')
require('../../y-memory/src/Memory.js')(Y)
require('../../y-array/src/Array.js')(Y)
require('../../y-map/src/Map.js')(Y)
require('../../y-indexeddb/src/IndexedDB.js')(Y)
module.exports = Y
var g
if (typeof global !== 'undefined') {
g = global
} else if (typeof window !== 'undefined') {
g = window
} else {
throw new Error('No global object?')
}
g.g = g
// Helper methods for the random number generator
Math.seedrandom = require('seedrandom')
g.generateRandomSeed = function generateRandomSeed () {
var seed
if (typeof window !== 'undefined' && window.location.hash.length > 1) {
seed = window.location.hash.slice(1) // first character is the hash!
console.warn('Using random seed that was specified in the url!')
} else {
seed = JSON.stringify(Math.random())
}
console.info('Using random seed: ' + seed)
g.setRandomSeed(seed)
}
g.setRandomSeed = function setRandomSeed (seed) {
Math.seedrandom.currentSeed = seed
Math.seedrandom(Math.seedrandom.currentSeed, { global: true })
}
g.generateRandomSeed()
g.YConcurrency_TestingMode = true
jasmine.DEFAULT_TIMEOUT_INTERVAL = 200000
g.describeManyTimes = function describeManyTimes (times, name, f) {
for (var i = 0; i < times; i++) {
describe(name, f)
}
}
/*
Wait for a specified amount of time (in ms). defaults to 5ms
*/
function wait (t) {
if (t == null) {
t = 0
}
return new Promise(function (resolve) {
setTimeout(function () {
resolve()
}, t)
})
}
g.wait = wait
g.databases = ['memory']
if (typeof window !== 'undefined') {
g.databases.push('indexeddb')
} else {
g.databases.push('leveldb')
}
/*
returns a random element of o.
works on Object, and Array
*/
function getRandom (o) {
if (o instanceof Array) {
return o[Math.floor(Math.random() * o.length)]
} else if (o.constructor === Object) {
return o[getRandom(Object.keys(o))]
}
}
g.getRandom = getRandom
function getRandomNumber (n) {
if (n == null) {
n = 9999
}
return Math.floor(Math.random() * n)
}
g.getRandomNumber = getRandomNumber
function getRandomString () {
var chars = 'abcdefghijklmnopqrstuvwxyzäüöABCDEFGHIJKLMNOPQRSTUVWXYZÄÜÖ'
var char = chars[getRandomNumber(chars.length)] // ü\n\n\n\n\n\n\n'
var length = getRandomNumber(7)
var string = ''
for (var i = 0; i < length; i++) {
string += char
}
return string
}
g.getRandomString = getRandomString
function * applyTransactions (relAmount, numberOfTransactions, objects, users, transactions, noReconnect) {
g.generateRandomSeed() // create a new seed, so we can re-create the behavior
for (var i = 0; i < numberOfTransactions * relAmount + 1; i++) {
var r = Math.random()
if (r > 0.95) {
// 10% chance of toggling concurrent user interactions.
// There will be an artificial delay until ops can be executed by the type,
// therefore, operations of the database will be (pre)transformed until user operations arrive
yield (function simulateConcurrentUserInteractions (type) {
if (!(type instanceof Y.utils.CustomType) && type.y instanceof Y.utils.CustomType) {
// usually we expect type to be a custom type. But in YXml we share an object {y: YXml, dom: Dom} instead
type = type.y
}
if (type.eventHandler.awaiting === 0 && type.eventHandler._debuggingAwaiting !== true) {
type.eventHandler.awaiting = 1
type.eventHandler._debuggingAwaiting = true
} else {
// fixAwaitingInType will handle _debuggingAwaiting
return fixAwaitingInType(type)
}
})(getRandom(objects))
} else if (r >= 0.5) {
// 40% chance to flush
yield Y.utils.globalRoom.flushOne() // flushes for some user.. (not necessarily 0)
} else if (noReconnect || r >= 0.05) {
// 45% chance to create operation
var done = getRandom(transactions)(getRandom(objects))
if (done != null) {
yield done
} else {
yield wait()
}
yield Y.utils.globalRoom.whenTransactionsFinished()
} else {
// 5% chance to disconnect/reconnect
var u = getRandom(users)
yield Promise.all(objects.map(fixAwaitingInType))
if (u.connector.isDisconnected()) {
yield u.reconnect()
} else {
yield u.disconnect()
}
yield Promise.all(objects.map(fixAwaitingInType))
}
}
}
function fixAwaitingInType (type) {
if (!(type instanceof Y.utils.CustomType) && type.y instanceof Y.utils.CustomType) {
// usually we expect type to be a custom type. But in YXml we share an object {y: YXml, dom: Dom} instead
type = type.y
}
return new Promise(function (resolve) {
type.os.whenTransactionsFinished().then(function () {
// _debuggingAwaiting artificially increases the awaiting property. We need to make sure that we only do that once / reverse the effect once
type.os.requestTransaction(function * () {
if (type.eventHandler.awaiting > 0 && type.eventHandler._debuggingAwaiting === true) {
type.eventHandler._debuggingAwaiting = false
yield* type.eventHandler.awaitOps(this, function * () { /* mock function */ })
}
wait(50).then(type.os.whenTransactionsFinished()).then(wait(50)).then(resolve)
})
})
})
}
g.fixAwaitingInType = fixAwaitingInType
g.applyRandomTransactionsNoGCNoDisconnect = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield* applyTransactions(1, numberOfTransactions, objects, users, transactions, true)
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
})
g.applyRandomTransactionsAllRejoinNoGC = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield* applyTransactions(1, numberOfTransactions, objects, users, transactions)
yield Promise.all(objects.map(fixAwaitingInType))
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
for (var u in users) {
yield Promise.all(objects.map(fixAwaitingInType))
yield users[u].reconnect()
yield Promise.all(objects.map(fixAwaitingInType))
}
yield Promise.all(objects.map(fixAwaitingInType))
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
yield g.garbageCollectAllUsers(users)
})
g.applyRandomTransactionsWithGC = async(function * applyRandomTransactions (users, objects, transactions, numberOfTransactions) {
yield* applyTransactions(1, numberOfTransactions, objects, users.slice(1), transactions)
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
for (var u in users) {
// TODO: here, we enforce that two users never sync at the same time with u[0]
// enforce that in the connector itself!
yield users[u].reconnect()
}
yield Y.utils.globalRoom.flushAll()
yield Promise.all(objects.map(fixAwaitingInType))
yield g.garbageCollectAllUsers(users)
})
g.garbageCollectAllUsers = async(function * garbageCollectAllUsers (users) {
yield Y.utils.globalRoom.flushAll()
for (var i in users) {
yield users[i].db.emptyGarbageCollector()
}
})
g.compareAllUsers = async(function * compareAllUsers (users) {
var s1, s2 // state sets
var ds1, ds2 // delete sets
var allDels1, allDels2 // all deletions
var db1 = [] // operation store of user1
yield Y.utils.globalRoom.flushAll()
yield g.garbageCollectAllUsers(users)
yield Y.utils.globalRoom.flushAll()
// disconnect, then reconnect all users
// We do this to make sure that the gc is updated by everyone
for (var i = 0; i < users.length; i++) {
yield users[i].disconnect()
yield wait()
yield users[i].reconnect()
}
yield wait()
yield Y.utils.globalRoom.flushAll()
// t1 and t2 basically do the same. They define t[1,2], ds[1,2], and allDels[1,2]
function * t1 () {
s1 = yield* this.getStateSet()
ds1 = yield* this.getDeleteSet()
allDels1 = []
yield* this.ds.iterate(this, null, null, function * (d) {
allDels1.push(d)
})
}
function * t2 () {
s2 = yield* this.getStateSet()
ds2 = yield* this.getDeleteSet()
allDels2 = []
yield* this.ds.iterate(this, null, null, function * (d) {
allDels2.push(d)
})
}
var buffer = Y.utils.globalRoom.buffers
for (var name in buffer) {
if (buffer[name].length > 0) {
// not all ops were transmitted..
debugger // eslint-disable-line
}
}
for (var uid = 0; uid < users.length; uid++) {
var u = users[uid]
u.db.requestTransaction(function * () {
var sv = yield* this.getStateVector()
for (var s of sv) {
yield* this.updateState(s.user)
}
// compare deleted ops against deleteStore
yield* this.os.iterate(this, null, null, function * (o) {
if (o.deleted === true) {
expect(yield* this.isDeleted(o.id)).toBeTruthy()
}
})
// compare deleteStore against deleted ops
var ds = []
yield* this.ds.iterate(this, null, null, function * (d) {
ds.push(d)
})
for (var j in ds) {
var d = ds[j]
for (var i = 0; i < d.len; i++) {
var o = yield* this.getInsertion([d.id[0], d.id[1] + i])
// gc'd or deleted
if (d.gc) {
expect(o).toBeFalsy()
} else {
expect(o.deleted).toBeTruthy()
}
}
}
})
// compare allDels tree
if (s1 == null) {
u.db.requestTransaction(function * () {
yield* t1.call(this)
yield* this.os.iterate(this, null, null, function * (o) {
o = Y.utils.copyObject(o)
delete o.origin
delete o.originOf
db1.push(o)
})
})
} else {
u.db.requestTransaction(function * () {
yield* t2.call(this)
var db2 = []
yield* this.os.iterate(this, null, null, function * (o) {
o = Y.utils.copyObject(o)
delete o.origin
delete o.originOf
db2.push(o)
})
expect(s1).toEqual(s2)
expect(allDels1).toEqual(allDels2) // inner structure
expect(ds1).toEqual(ds2) // exported structure
db2.forEach((o, i) => {
expect(db1[i]).toEqual(o)
})
})
}
yield u.db.whenTransactionsFinished()
}
})
g.createUsers = async(function * createUsers (self, numberOfUsers, database, initType) {
if (Y.utils.globalRoom.users[0] != null) {
yield Y.utils.globalRoom.flushAll()
}
// destroy old users
for (var u in Y.utils.globalRoom.users) {
Y.utils.globalRoom.users[u].y.destroy()
}
self.users = null
var promises = []
for (var i = 0; i < numberOfUsers; i++) {
promises.push(Y({
db: {
name: database,
namespace: 'User ' + i,
cleanStart: true,
gcTimeout: -1,
gc: true,
repairCheckInterval: -1
},
connector: {
name: 'Test',
debug: false
},
share: {
root: initType || 'Map'
}
}))
}
self.users = yield Promise.all(promises)
self.types = self.users.map(function (u) { return u.share.root })
return self.users
})
/*
Until async/await arrives in js, we use this function to wait for promises
by yielding them.
*/
function async (makeGenerator) {
return function (arg) {
var generator = makeGenerator.apply(this, arguments)
function handle (result) {
if (result.done) return Promise.resolve(result.value)
return Promise.resolve(result.value).then(function (res) {
return handle(generator.next(res))
}, function (err) {
return handle(generator.throw(err))
})
}
try {
return handle(generator.next())
} catch (ex) {
generator.throw(ex)
// return Promise.reject(ex)
}
}
}
g.async = async
function logUsers (self) {
if (self.constructor === Array) {
self = {users: self}
}
self.users[0].db.logTable()
self.users[1].db.logTable()
self.users[2].db.logTable()
}
g.logUsers = logUsers

414
src/Struct.js Normal file
View File

@ -0,0 +1,414 @@
/* @flow */
'use strict'
/*
An operation also defines the structure of a type. This is why operation and
structure are used interchangeably here.
It must be of the type Object. I hope to achieve some performance
improvements when working on databases that support the json format.
An operation must have the following properties:
* encode
- Encode the structure in a readable format (preferably string- todo)
* decode (todo)
- decode structure to json
* execute
- Execute the semantics of an operation.
* requiredOps
- Operations that are required to execute this operation.
*/
module.exports = function (Y/* :any */) {
var Struct = {
/* This is the only operation that is actually not a structure, because
it is not stored in the OS. This is why it _does not_ have an id
op = {
target: Id
}
*/
Delete: {
encode: function (op) {
return {
target: op.target,
length: op.length || 0,
struct: 'Delete'
}
},
requiredOps: function (op) {
return [] // [op.target]
},
execute: function * (op) {
return yield* this.deleteOperation(op.target, op.length || 1)
}
},
Insert: {
/* {
content: [any],
opContent: Id,
id: Id,
left: Id,
origin: Id,
right: Id,
parent: Id,
parentSub: string (optional), // child of Map type
}
*/
encode: function (op/* :Insertion */) /* :Insertion */ {
// TODO: you could not send the "left" property, then you also have to
// "op.left = null" in $execute or $decode
var e/* :any */ = {
id: op.id,
left: op.left,
right: op.right,
origin: op.origin,
parent: op.parent,
struct: op.struct
}
if (op.parentSub != null) {
e.parentSub = op.parentSub
}
if (op.hasOwnProperty('opContent')) {
e.opContent = op.opContent
} else {
e.content = op.content.slice()
}
return e
},
requiredOps: function (op) {
var ids = []
if (op.left != null) {
ids.push(op.left)
}
if (op.right != null) {
ids.push(op.right)
}
if (op.origin != null && !Y.utils.compareIds(op.left, op.origin)) {
ids.push(op.origin)
}
// if (op.right == null && op.left == null) {
ids.push(op.parent)
if (op.opContent != null) {
ids.push(op.opContent)
}
return ids
},
getDistanceToOrigin: function * (op) {
if (op.left == null) {
return 0
} else {
var d = 0
var o = yield* this.getInsertion(op.left)
while (!Y.utils.matchesId(o, op.origin)) {
d++
if (o.left == null) {
break
} else {
o = yield* this.getInsertion(o.left)
}
}
return d
}
},
/*
# $this has to find a unique position between origin and the next known character
# case 1: $origin equals $o.origin: the $creator parameter decides if left or right
# let $OL= [o1,o2,o3,o4], whereby $this is to be inserted between o1 and o4
# o2,o3 and o4 origin is 1 (the position of o2)
# there is the case that $this.creator < o2.creator, but o3.creator < $this.creator
# then o2 knows o3. Since on another client $OL could be [o1,o3,o4] the problem is complex
# therefore $this would be always to the right of o3
# case 2: $origin < $o.origin
# if current $this insert_position > $o origin: $this ins
# else $insert_position will not change
# (maybe we encounter case 1 later, then this will be to the right of $o)
# case 3: $origin > $o.origin
# $this insert_position is to the left of $o (forever!)
*/
execute: function * (op) {
var i // loop counter
// during this function some ops may get split into two pieces (e.g. with getInsertionCleanEnd)
// We try to merge them later, if possible
var tryToRemergeLater = []
if (op.origin != null) { // TODO: !== instead of !=
// we save in origin that op originates in it
// we need that later when we eventually garbage collect origin (see transaction)
var origin = yield* this.getInsertionCleanEnd(op.origin)
if (origin.originOf == null) {
origin.originOf = []
}
origin.originOf.push(op.id)
yield* this.setOperation(origin)
if (origin.right != null) {
tryToRemergeLater.push(origin.right)
}
}
var distanceToOrigin = i = yield* Struct.Insert.getDistanceToOrigin.call(this, op) // most cases: 0 (starts from 0)
// now we begin to insert op in the list of insertions..
var o
var parent
var start
// find o. o is the first conflicting operation
if (op.left != null) {
o = yield* this.getInsertionCleanEnd(op.left)
if (!Y.utils.compareIds(op.left, op.origin) && o.right != null) {
// only if not added previously
tryToRemergeLater.push(o.right)
}
o = (o.right == null) ? null : yield* this.getOperation(o.right)
} else { // left == null
parent = yield* this.getOperation(op.parent)
let startId = op.parentSub ? parent.map[op.parentSub] : parent.start
start = startId == null ? null : yield* this.getOperation(startId)
o = start
}
// make sure to split op.right if necessary (also add to tryCombineWithLeft)
if (op.right != null) {
tryToRemergeLater.push(op.right)
yield* this.getInsertionCleanStart(op.right)
}
// handle conflicts
while (true) {
if (o != null && !Y.utils.compareIds(o.id, op.right)) {
var oOriginDistance = yield* Struct.Insert.getDistanceToOrigin.call(this, o)
if (oOriginDistance === i) {
// case 1
if (o.id[0] < op.id[0]) {
op.left = Y.utils.getLastId(o)
distanceToOrigin = i + 1 // just ignore o.content.length, doesn't make a difference
}
} else if (oOriginDistance < i) {
// case 2
if (i - distanceToOrigin <= oOriginDistance) {
op.left = Y.utils.getLastId(o)
distanceToOrigin = i + 1 // just ignore o.content.length, doesn't make a difference
}
} else {
break
}
i++
if (o.right != null) {
o = yield* this.getInsertion(o.right)
} else {
o = null
}
} else {
break
}
}
// reconnect..
var left = null
var right = null
if (parent == null) {
parent = yield* this.getOperation(op.parent)
}
// reconnect left and set right of op
if (op.left != null) {
left = yield* this.getInsertion(op.left)
// link left
op.right = left.right
left.right = op.id
yield* this.setOperation(left)
} else {
// set op.right from parent, if necessary
op.right = op.parentSub ? parent.map[op.parentSub] || null : parent.start
}
// reconnect right
if (op.right != null) {
// TODO: wanna connect right too?
right = yield* this.getOperation(op.right)
right.left = Y.utils.getLastId(op)
// if right exists, and it is supposed to be gc'd. Remove it from the gc
if (right.gc != null) {
if (right.content != null && right.content.length > 1) {
right = yield* this.getInsertionCleanEnd(right.id)
}
this.store.removeFromGarbageCollector(right)
}
yield* this.setOperation(right)
}
// update parents .map/start/end properties
if (op.parentSub != null) {
if (left == null) {
parent.map[op.parentSub] = op.id
yield* this.setOperation(parent)
}
// is a child of a map struct.
// Then also make sure that only the most left element is not deleted
// We do not call the type in this case (this is what the third parameter is for)
if (op.right != null) {
yield* this.deleteOperation(op.right, 1, true)
}
if (op.left != null) {
yield* this.deleteOperation(op.id, 1, true)
}
} else {
if (right == null || left == null) {
if (right == null) {
parent.end = Y.utils.getLastId(op)
}
if (left == null) {
parent.start = op.id
}
yield* this.setOperation(parent)
}
}
// try to merge original op.left and op.origin
for (i = 0; i < tryToRemergeLater.length; i++) {
var m = yield* this.getOperation(tryToRemergeLater[i])
yield* this.tryCombineWithLeft(m)
}
}
},
List: {
/*
{
start: null,
end: null,
struct: "List",
type: "",
id: this.os.getNextOpId(1)
}
*/
create: function (id) {
return {
start: null,
end: null,
struct: 'List',
id: id
}
},
encode: function (op) {
var e = {
struct: 'List',
id: op.id,
type: op.type
}
if (op.requires != null) {
e.requires = op.requires
}
if (op.info != null) {
e.info = op.info
}
return e
},
requiredOps: function () {
/*
var ids = []
if (op.start != null) {
ids.push(op.start)
}
if (op.end != null){
ids.push(op.end)
}
return ids
*/
return []
},
execute: function * (op) {
op.start = null
op.end = null
},
ref: function * (op, pos) {
if (op.start == null) {
return null
}
var res = null
var o = yield* this.getOperation(op.start)
while (true) {
if (!o.deleted) {
res = o
pos--
}
if (pos >= 0 && o.right != null) {
o = yield* this.getOperation(o.right)
} else {
break
}
}
return res
},
map: function * (o, f) {
o = o.start
var res = []
while (o != null) { // TODO: change to != (at least some convention)
var operation = yield* this.getOperation(o)
if (!operation.deleted) {
res.push(f(operation))
}
o = operation.right
}
return res
}
},
Map: {
/*
{
map: {},
struct: "Map",
type: "",
id: this.os.getNextOpId(1)
}
*/
create: function (id) {
return {
id: id,
map: {},
struct: 'Map'
}
},
encode: function (op) {
var e = {
struct: 'Map',
type: op.type,
id: op.id,
map: {} // overwrite map!!
}
if (op.requires != null) {
e.requires = op.requires
}
if (op.info != null) {
e.info = op.info
}
return e
},
requiredOps: function () {
return []
},
execute: function * () {},
/*
Get a property by name
*/
get: function * (op, name) {
var oid = op.map[name]
if (oid != null) {
var res = yield* this.getOperation(oid)
if (res == null || res.deleted) {
return void 0
} else if (res.opContent == null) {
return res.content[0]
} else {
return yield* this.getType(res.opContent)
}
}
}
}
}
Y.Struct = Struct
}

1098
src/Transaction.js Normal file

File diff suppressed because it is too large Load Diff

795
src/Utils.js Normal file
View File

@ -0,0 +1,795 @@
/* @flow */
'use strict'
/*
EventHandler is an helper class for constructing custom types.
Why: When constructing custom types, you sometimes want your types to work
synchronous: E.g.
``` Synchronous
mytype.setSomething("yay")
mytype.getSomething() === "yay"
```
versus
``` Asynchronous
mytype.setSomething("yay")
mytype.getSomething() === undefined
mytype.waitForSomething().then(function(){
mytype.getSomething() === "yay"
})
```
The structures usually work asynchronously (you have to wait for the
database request to finish). EventHandler helps you to make your type
synchronous.
*/
module.exports = function (Y /* : any*/) {
Y.utils = {}
Y.utils.bubbleEvent = function (type, event) {
type.eventHandler.callEventListeners(event)
event.path = []
while (type != null && type._deepEventHandler != null) {
type._deepEventHandler.callEventListeners(event)
var parent = null
if (type._parent != null) {
parent = type.os.getType(type._parent)
}
if (parent != null && parent._getPathToChild != null) {
event.path = [parent._getPathToChild(type._model)].concat(event.path)
type = parent
} else {
type = null
}
}
}
class EventListenerHandler {
constructor () {
this.eventListeners = []
}
destroy () {
this.eventListeners = null
}
/*
Basic event listener boilerplate...
*/
addEventListener (f) {
this.eventListeners.push(f)
}
removeEventListener (f) {
this.eventListeners = this.eventListeners.filter(function (g) {
return f !== g
})
}
removeAllEventListeners () {
this.eventListeners = []
}
callEventListeners (event) {
for (var i = 0; i < this.eventListeners.length; i++) {
try {
var _event = {}
for (var name in event) {
_event[name] = event[name]
}
this.eventListeners[i](_event)
} catch (e) {
console.error('Your observer threw an error. This error was caught so that Yjs still can ensure data consistency! In order to debug this error you have to check "Pause On Caught Exceptions"', e)
}
}
}
}
Y.utils.EventListenerHandler = EventListenerHandler
class EventHandler extends EventListenerHandler {
/* ::
waiting: Array<Insertion | Deletion>;
awaiting: number;
onevent: Function;
eventListeners: Array<Function>;
*/
/*
onevent: is called when the structure changes.
Note: "awaiting opertations" is used to denote operations that were
prematurely called. Events for received operations can not be executed until
all prematurely called operations were executed ("waiting operations")
*/
constructor (onevent /* : Function */) {
super()
this.waiting = []
this.awaiting = 0
this.onevent = onevent
}
destroy () {
super.destroy()
this.waiting = null
this.onevent = null
}
/*
Call this when a new operation arrives. It will be executed right away if
there are no waiting operations, that you prematurely executed
*/
receivedOp (op) {
if (this.awaiting <= 0) {
this.onevent(op)
} else if (op.struct === 'Delete') {
var self = this
var checkDelete = function checkDelete (d) {
if (d.length == null) {
throw new Error('This shouldn\'t happen! d.length must be defined!')
}
// we check if o deletes something in self.waiting
// if so, we remove the deleted operation
for (var w = 0; w < self.waiting.length; w++) {
var i = self.waiting[w]
if (i.struct === 'Insert' && i.id[0] === d.target[0]) {
var iLength = i.hasOwnProperty('content') ? i.content.length : 1
var dStart = d.target[1]
var dEnd = d.target[1] + (d.length || 1)
var iStart = i.id[1]
var iEnd = i.id[1] + iLength
// Check if they don't overlap
if (iEnd <= dStart || dEnd <= iStart) {
// no overlapping
continue
}
// we check all overlapping cases. All cases:
/*
1) iiiii
ddddd
--> modify i and d
2) iiiiiii
ddddd
--> modify i, remove d
3) iiiiiii
ddd
--> remove d, modify i, and create another i (for the right hand side)
4) iiiii
ddddddd
--> remove i, modify d
5) iiiiiii
ddddddd
--> remove both i and d (**)
6) iiiiiii
ddddd
--> modify i, remove d
7) iii
ddddddd
--> remove i, create and apply two d with checkDelete(d) (**)
8) iiiii
ddddddd
--> remove i, modify d (**)
9) iiiii
ddddd
--> modify i and d
(**) (also check if i contains content or type)
*/
// TODO: I left some debugger statements, because I want to debug all cases once in production. REMEMBER END TODO
if (iStart < dStart) {
if (dStart < iEnd) {
if (iEnd < dEnd) {
// Case 1
// remove the right part of i's content
i.content.splice(dStart - iStart)
// remove the start of d's deletion
d.length = dEnd - iEnd
d.target = [d.target[0], iEnd]
continue
} else if (iEnd === dEnd) {
// Case 2
i.content.splice(dStart - iStart)
// remove d, we do that by simply ending this function
return
} else { // (dEnd < iEnd)
// Case 3
var newI = {
id: [i.id[0], dEnd],
content: i.content.slice(dEnd - iStart),
struct: 'Insert'
}
self.waiting.push(newI)
i.content.splice(dStart - iStart)
return
}
}
} else if (dStart === iStart) {
if (iEnd < dEnd) {
// Case 4
d.length = dEnd - iEnd
d.target = [d.target[0], iEnd]
i.content = []
continue
} else if (iEnd === dEnd) {
// Case 5
self.waiting.splice(w, 1)
return
} else { // (dEnd < iEnd)
// Case 6
i.content = i.content.slice(dEnd - iStart)
i.id = [i.id[0], dEnd]
return
}
} else { // (dStart < iStart)
if (iStart < dEnd) {
// they overlap
/*
7) iii
ddddddd
--> remove i, create and apply two d with checkDelete(d) (**)
8) iiiii
ddddddd
--> remove i, modify d (**)
9) iiiii
ddddd
--> modify i and d
*/
if (iEnd < dEnd) {
// Case 7
// debugger // TODO: You did not test this case yet!!!! (add the debugger here)
self.waiting.splice(w, 1)
checkDelete({
target: [d.target[0], dStart],
length: iStart - dStart,
struct: 'Delete'
})
checkDelete({
target: [d.target[0], iEnd],
length: iEnd - dEnd,
struct: 'Delete'
})
return
} else if (iEnd === dEnd) {
// Case 8
self.waiting.splice(w, 1)
w--
d.length -= iLength
continue
} else { // dEnd < iEnd
// Case 9
d.length = iStart - dStart
i.content.splice(0, dEnd - iStart)
i.id = [i.id[0], dEnd]
continue
}
}
}
}
}
// finished with remaining operations
self.waiting.push(d)
}
if (op.key == null) {
// deletes in list
checkDelete(op)
} else {
// deletes in map
this.waiting.push(op)
}
} else {
this.waiting.push(op)
}
}
/*
You created some operations, and you want the `onevent` function to be
called right away. Received operations will not be executed untill all
prematurely called operations are executed
*/
awaitAndPrematurelyCall (ops) {
this.awaiting++
ops.map(Y.utils.copyOperation).forEach(this.onevent)
}
* awaitOps (transaction, f, args) {
function notSoSmartSort (array) {
// this function sorts insertions in a executable order
var result = []
while (array.length > 0) {
for (var i = 0; i < array.length; i++) {
var independent = true
for (var j = 0; j < array.length; j++) {
if (Y.utils.matchesId(array[j], array[i].left)) {
// array[i] depends on array[j]
independent = false
break
}
}
if (independent) {
result.push(array.splice(i, 1)[0])
i--
}
}
}
return result
}
var before = this.waiting.length
// somehow create new operations
yield* f.apply(transaction, args)
// remove all appended ops / awaited ops
this.waiting.splice(before)
if (this.awaiting > 0) this.awaiting--
// if there are no awaited ops anymore, we can update all waiting ops, and send execute them (if there are still no awaited ops)
if (this.awaiting === 0 && this.waiting.length > 0) {
// update all waiting ops
for (let i = 0; i < this.waiting.length; i++) {
var o = this.waiting[i]
if (o.struct === 'Insert') {
var _o = yield* transaction.getInsertion(o.id)
if (_o.parentSub != null && _o.left != null) {
// if o is an insertion of a map struc (parentSub is defined), then it shouldn't be necessary to compute left
this.waiting.splice(i, 1)
i-- // update index
} else if (!Y.utils.compareIds(_o.id, o.id)) {
// o got extended
o.left = [o.id[0], o.id[1] - 1]
} else if (_o.left == null) {
o.left = null
} else {
// find next undeleted op
var left = yield* transaction.getInsertion(_o.left)
while (left.deleted != null) {
if (left.left != null) {
left = yield* transaction.getInsertion(left.left)
} else {
left = null
break
}
}
o.left = left != null ? Y.utils.getLastId(left) : null
}
}
}
// the previous stuff was async, so we have to check again!
// We also pull changes from the bindings, if there exists such a method, this could increase awaiting too
if (this._pullChanges != null) {
this._pullChanges()
}
if (this.awaiting === 0) {
// sort by type, execute inserts first
var ins = []
var dels = []
this.waiting.forEach(function (o) {
if (o.struct === 'Delete') {
dels.push(o)
} else {
ins.push(o)
}
})
this.waiting = []
// put in executable order
ins = notSoSmartSort(ins)
// this.onevent can trigger the creation of another operation
// -> check if this.awaiting increased & stop computation if it does
for (var i = 0; i < ins.length; i++) {
if (this.awaiting === 0) {
this.onevent(ins[i])
} else {
this.waiting = this.waiting.concat(ins.slice(i))
break
}
}
for (i = 0; i < dels.length; i++) {
if (this.awaiting === 0) {
this.onevent(dels[i])
} else {
this.waiting = this.waiting.concat(dels.slice(i))
break
}
}
}
}
}
// TODO: Remove awaitedInserts and awaitedDeletes in favor of awaitedOps, as they are deprecated and do not always work
// Do this in one of the coming releases that are breaking anyway
/*
Call this when you successfully awaited the execution of n Insert operations
*/
awaitedInserts (n) {
var ops = this.waiting.splice(this.waiting.length - n)
for (var oid = 0; oid < ops.length; oid++) {
var op = ops[oid]
if (op.struct === 'Insert') {
for (var i = this.waiting.length - 1; i >= 0; i--) {
let w = this.waiting[i]
// TODO: do I handle split operations correctly here? Super unlikely, but yeah..
// Also: can this case happen? Can op be inserted in the middle of a larger op that is in $waiting?
if (w.struct === 'Insert') {
if (Y.utils.matchesId(w, op.left)) {
// include the effect of op in w
w.right = op.id
// exclude the effect of w in op
op.left = w.left
} else if (Y.utils.compareIds(w.id, op.right)) {
// similar..
w.left = Y.utils.getLastId(op)
op.right = w.right
}
}
}
} else {
throw new Error('Expected Insert Operation!')
}
}
this._tryCallEvents(n)
}
/*
Call this when you successfully awaited the execution of n Delete operations
*/
awaitedDeletes (n, newLeft) {
var ops = this.waiting.splice(this.waiting.length - n)
for (var j = 0; j < ops.length; j++) {
var del = ops[j]
if (del.struct === 'Delete') {
if (newLeft != null) {
for (var i = 0; i < this.waiting.length; i++) {
let w = this.waiting[i]
// We will just care about w.left
if (w.struct === 'Insert' && Y.utils.compareIds(del.target, w.left)) {
w.left = newLeft
}
}
}
} else {
throw new Error('Expected Delete Operation!')
}
}
this._tryCallEvents(n)
}
/* (private)
Try to execute the events for the waiting operations
*/
_tryCallEvents () {
function notSoSmartSort (array) {
var result = []
while (array.length > 0) {
for (var i = 0; i < array.length; i++) {
var independent = true
for (var j = 0; j < array.length; j++) {
if (Y.utils.matchesId(array[j], array[i].left)) {
// array[i] depends on array[j]
independent = false
break
}
}
if (independent) {
result.push(array.splice(i, 1)[0])
i--
}
}
}
return result
}
if (this.awaiting > 0) this.awaiting--
if (this.awaiting === 0 && this.waiting.length > 0) {
var ins = []
var dels = []
this.waiting.forEach(function (o) {
if (o.struct === 'Delete') {
dels.push(o)
} else {
ins.push(o)
}
})
ins = notSoSmartSort(ins)
ins.forEach(this.onevent)
dels.forEach(this.onevent)
this.waiting = []
}
}
}
Y.utils.EventHandler = EventHandler
/*
Default class of custom types!
*/
class CustomType {
getPath () {
var parent = null
if (this._parent != null) {
parent = this.os.getType(this._parent)
}
if (parent != null && parent._getPathToChild != null) {
var firstKey = parent._getPathToChild(this._model)
var parentKeys = parent.getPath()
parentKeys.push(firstKey)
return parentKeys
} else {
return []
}
}
}
Y.utils.CustomType = CustomType
/*
A wrapper for the definition of a custom type.
Every custom type must have three properties:
* struct
- Structname of this type
* initType
- Given a model, creates a custom type
* class
- the constructor of the custom type (e.g. in order to inherit from a type)
*/
class CustomTypeDefinition { // eslint-disable-line
/* ::
struct: any;
initType: any;
class: Function;
name: String;
*/
constructor (def) {
if (def.struct == null ||
def.initType == null ||
def.class == null ||
def.name == null ||
def.createType == null
) {
throw new Error('Custom type was not initialized correctly!')
}
this.struct = def.struct
this.initType = def.initType
this.createType = def.createType
this.class = def.class
this.name = def.name
if (def.appendAdditionalInfo != null) {
this.appendAdditionalInfo = def.appendAdditionalInfo
}
this.parseArguments = (def.parseArguments || function () {
return [this]
}).bind(this)
this.parseArguments.typeDefinition = this
}
}
Y.utils.CustomTypeDefinition = CustomTypeDefinition
Y.utils.isTypeDefinition = function isTypeDefinition (v) {
if (v != null) {
if (v instanceof Y.utils.CustomTypeDefinition) return [v]
else if (v.constructor === Array && v[0] instanceof Y.utils.CustomTypeDefinition) return v
else if (v instanceof Function && v.typeDefinition instanceof Y.utils.CustomTypeDefinition) return [v.typeDefinition]
}
return false
}
/*
Make a flat copy of an object
(just copy properties)
*/
function copyObject (o) {
var c = {}
for (var key in o) {
c[key] = o[key]
}
return c
}
Y.utils.copyObject = copyObject
/*
Copy an operation, so that it can be manipulated.
Note: You must not change subproperties (except o.content)!
*/
function copyOperation (o) {
o = copyObject(o)
if (o.content != null) {
o.content = o.content.map(function (c) { return c })
}
return o
}
Y.utils.copyOperation = copyOperation
/*
Defines a smaller relation on Id's
*/
function smaller (a, b) {
return a[0] < b[0] || (a[0] === b[0] && (a[1] < b[1] || typeof a[1] < typeof b[1]))
}
Y.utils.smaller = smaller
function inDeletionRange (del, ins) {
return del.target[0] === ins[0] && del.target[1] <= ins[1] && ins[1] < del.target[1] + (del.length || 1)
}
Y.utils.inDeletionRange = inDeletionRange
function compareIds (id1, id2) {
if (id1 == null || id2 == null) {
return id1 === id2
} else {
return id1[0] === id2[0] && id1[1] === id2[1]
}
}
Y.utils.compareIds = compareIds
function matchesId (op, id) {
if (id == null || op == null) {
return id === op
} else {
if (id[0] === op.id[0]) {
if (op.content == null) {
return id[1] === op.id[1]
} else {
return id[1] >= op.id[1] && id[1] < op.id[1] + op.content.length
}
}
}
}
Y.utils.matchesId = matchesId
function getLastId (op) {
if (op.content == null || op.content.length === 1) {
return op.id
} else {
return [op.id[0], op.id[1] + op.content.length - 1]
}
}
Y.utils.getLastId = getLastId
function createEmptyOpsArray (n) {
var a = new Array(n)
for (var i = 0; i < a.length; i++) {
a[i] = {
id: [null, null]
}
}
return a
}
function createSmallLookupBuffer (Store) {
/*
This buffer implements a very small buffer that temporarily stores operations
after they are read / before they are written.
The buffer basically implements FIFO. Often requested lookups will be re-queued every time they are looked up / written.
It can speed up lookups on Operation Stores and State Stores. But it does not require notable use of memory or processing power.
Good for os and ss, bot not for ds (because it often uses methods that require a flush)
I tried to optimize this for performance, therefore no highlevel operations.
*/
class SmallLookupBuffer extends Store {
constructor (arg1, arg2) {
// super(...arguments) -- do this when this is supported by stable nodejs
super(arg1, arg2)
this.writeBuffer = createEmptyOpsArray(5)
this.readBuffer = createEmptyOpsArray(10)
}
* find (id, noSuperCall) {
var i, r
for (i = this.readBuffer.length - 1; i >= 0; i--) {
r = this.readBuffer[i]
// we don't have to use compareids, because id is always defined!
if (r.id[1] === id[1] && r.id[0] === id[0]) {
// found r
// move r to the end of readBuffer
for (; i < this.readBuffer.length - 1; i++) {
this.readBuffer[i] = this.readBuffer[i + 1]
}
this.readBuffer[this.readBuffer.length - 1] = r
return r
}
}
var o
for (i = this.writeBuffer.length - 1; i >= 0; i--) {
r = this.writeBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
o = r
break
}
}
if (i < 0 && noSuperCall === undefined) {
// did not reach break in last loop
// read id and put it to the end of readBuffer
o = yield* super.find(id)
}
if (o != null) {
for (i = 0; i < this.readBuffer.length - 1; i++) {
this.readBuffer[i] = this.readBuffer[i + 1]
}
this.readBuffer[this.readBuffer.length - 1] = o
}
return o
}
* put (o) {
var id = o.id
var i, r // helper variables
for (i = this.writeBuffer.length - 1; i >= 0; i--) {
r = this.writeBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
// is already in buffer
// forget r, and move o to the end of writeBuffer
for (; i < this.writeBuffer.length - 1; i++) {
this.writeBuffer[i] = this.writeBuffer[i + 1]
}
this.writeBuffer[this.writeBuffer.length - 1] = o
break
}
}
if (i < 0) {
// did not reach break in last loop
// write writeBuffer[0]
var write = this.writeBuffer[0]
if (write.id[0] !== null) {
yield* super.put(write)
}
// put o to the end of writeBuffer
for (i = 0; i < this.writeBuffer.length - 1; i++) {
this.writeBuffer[i] = this.writeBuffer[i + 1]
}
this.writeBuffer[this.writeBuffer.length - 1] = o
}
// check readBuffer for every occurence of o.id, overwrite if found
// whether found or not, we'll append o to the readbuffer
for (i = 0; i < this.readBuffer.length - 1; i++) {
r = this.readBuffer[i + 1]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
this.readBuffer[i] = o
} else {
this.readBuffer[i] = r
}
}
this.readBuffer[this.readBuffer.length - 1] = o
}
* delete (id) {
var i, r
for (i = 0; i < this.readBuffer.length; i++) {
r = this.readBuffer[i]
if (r.id[1] === id[1] && r.id[0] === id[0]) {
this.readBuffer[i] = {
id: [null, null]
}
}
}
yield* this.flush()
yield* super.delete(id)
}
* findWithLowerBound (id) {
var o = yield* this.find(id, true)
if (o != null) {
return o
} else {
yield* this.flush()
return yield* super.findWithLowerBound.apply(this, arguments)
}
}
* findWithUpperBound (id) {
var o = yield* this.find(id, true)
if (o != null) {
return o
} else {
yield* this.flush()
return yield* super.findWithUpperBound.apply(this, arguments)
}
}
* findNext () {
yield* this.flush()
return yield* super.findNext.apply(this, arguments)
}
* findPrev () {
yield* this.flush()
return yield* super.findPrev.apply(this, arguments)
}
* iterate () {
yield* this.flush()
yield* super.iterate.apply(this, arguments)
}
* flush () {
for (var i = 0; i < this.writeBuffer.length; i++) {
var write = this.writeBuffer[i]
if (write.id[0] !== null) {
yield* super.put(write)
this.writeBuffer[i] = {
id: [null, null]
}
}
}
}
}
return SmallLookupBuffer
}
Y.utils.createSmallLookupBuffer = createSmallLookupBuffer
// Generates a unique id, for use as a user id.
// Thx to @jed for this script https://gist.github.com/jed/982883
function generateGuid(a){return a?(a^Math.random()*16>>a/4).toString(16):([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,generateGuid)} // eslint-disable-line
Y.utils.generateGuid = generateGuid
}

View File

@ -1,135 +0,0 @@
/** eslint-env browser */
export {
Doc,
Transaction,
YArray as Array,
YMap as Map,
YText as Text,
YXmlText as XmlText,
YXmlHook as XmlHook,
YXmlElement as XmlElement,
YXmlFragment as XmlFragment,
YXmlEvent,
YMapEvent,
YArrayEvent,
YTextEvent,
YEvent,
Item,
AbstractStruct,
GC,
Skip,
ContentBinary,
ContentDeleted,
ContentDoc,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentAny,
ContentString,
ContentType,
AbstractType,
getTypeChildren,
createRelativePositionFromTypeIndex,
createRelativePositionFromJSON,
createAbsolutePositionFromRelativePosition,
compareRelativePositions,
AbsolutePosition,
RelativePosition,
ID,
createID,
compareIDs,
getState,
Snapshot,
createSnapshot,
createDeleteSet,
createDeleteSetFromStructStore,
cleanupYTextFormatting,
snapshot,
emptySnapshot,
findRootTypeKey,
findIndexSS,
getItem,
getItemCleanStart,
getItemCleanEnd,
typeListToArraySnapshot,
typeMapGetSnapshot,
typeMapGetAllSnapshot,
createDocFromSnapshot,
iterateDeletedStructs,
applyUpdate,
applyUpdateV2,
readUpdate,
readUpdateV2,
encodeStateAsUpdate,
encodeStateAsUpdateV2,
encodeStateVector,
UndoManager,
decodeSnapshot,
encodeSnapshot,
decodeSnapshotV2,
encodeSnapshotV2,
decodeStateVector,
logUpdate,
logUpdateV2,
decodeUpdate,
decodeUpdateV2,
relativePositionToJSON,
isDeleted,
isParentOf,
equalSnapshots,
PermanentUserData, // @TODO experimental
tryGc,
transact,
AbstractConnector,
logType,
mergeUpdates,
mergeUpdatesV2,
parseUpdateMeta,
parseUpdateMetaV2,
encodeStateVectorFromUpdate,
encodeStateVectorFromUpdateV2,
encodeRelativePosition,
decodeRelativePosition,
diffUpdate,
diffUpdateV2,
convertUpdateFormatV1ToV2,
convertUpdateFormatV2ToV1,
obfuscateUpdate,
obfuscateUpdateV2,
UpdateEncoderV1,
UpdateEncoderV2,
UpdateDecoderV1,
UpdateDecoderV2,
equalDeleteSets,
mergeDeleteSets,
snapshotContainsUpdate
} from './internals.js'
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
// @ts-ignore
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS$ __'
if (glo[importIdentifier] === true) {
/**
* Dear reader of this message. Please take this seriously.
*
* If you see this message, make sure that you only import one version of Yjs. In many cases,
* your package manager installs two versions of Yjs that are used by different packages within your project.
* Another reason for this message is that some parts of your project use the commonjs version of Yjs
* and others use the EcmaScript version of Yjs.
*
* This often leads to issues that are hard to debug. We often need to perform constructor checks,
* e.g. `struct instanceof GC`. If you imported different versions of Yjs, it is impossible for us to
* do the constructor checks anymore - which might break the CRDT algorithm.
*
* https://github.com/yjs/yjs/issues/438
*/
console.error('Yjs was already imported. This breaks constructor checks and will lead to issues! - https://github.com/yjs/yjs/issues/438')
}
glo[importIdentifier] = true

View File

@ -1,42 +0,0 @@
export * from './utils/AbstractConnector.js'
export * from './utils/DeleteSet.js'
export * from './utils/Doc.js'
export * from './utils/UpdateDecoder.js'
export * from './utils/UpdateEncoder.js'
export * from './utils/encoding.js'
export * from './utils/EventHandler.js'
export * from './utils/ID.js'
export * from './utils/isParentOf.js'
export * from './utils/logging.js'
export * from './utils/PermanentUserData.js'
export * from './utils/RelativePosition.js'
export * from './utils/Snapshot.js'
export * from './utils/StructStore.js'
export * from './utils/Transaction.js'
export * from './utils/UndoManager.js'
export * from './utils/updates.js'
export * from './utils/YEvent.js'
export * from './types/AbstractType.js'
export * from './types/YArray.js'
export * from './types/YMap.js'
export * from './types/YText.js'
export * from './types/YXmlFragment.js'
export * from './types/YXmlElement.js'
export * from './types/YXmlEvent.js'
export * from './types/YXmlHook.js'
export * from './types/YXmlText.js'
export * from './structs/AbstractStruct.js'
export * from './structs/GC.js'
export * from './structs/ContentBinary.js'
export * from './structs/ContentDeleted.js'
export * from './structs/ContentDoc.js'
export * from './structs/ContentEmbed.js'
export * from './structs/ContentFormat.js'
export * from './structs/ContentJSON.js'
export * from './structs/ContentAny.js'
export * from './structs/ContentString.js'
export * from './structs/ContentType.js'
export * from './structs/Item.js'
export * from './structs/Skip.js'

View File

@ -1,51 +0,0 @@
import {
UpdateEncoderV1, UpdateEncoderV2, ID, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
export class AbstractStruct {
/**
* @param {ID} id
* @param {number} length
*/
constructor (id, length) {
this.id = id
this.length = length
}
/**
* @type {boolean}
*/
get deleted () {
throw error.methodUnimplemented()
}
/**
* Merge this struct with the item to the right.
* This method is already assuming that `this.id.clock + this.length === this.id.clock`.
* Also this method does *not* remove right from StructStore!
* @param {AbstractStruct} right
* @return {boolean} whether this merged with right
*/
mergeWith (right) {
return false
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {number} offset
* @param {number} encodingRef
*/
write (encoder, offset, encodingRef) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction, offset) {
throw error.methodUnimplemented()
}
}

View File

@ -1,114 +0,0 @@
import {
UpdateEncoderV1, UpdateEncoderV2, UpdateDecoderV1, UpdateDecoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as env from 'lib0/environment'
import * as object from 'lib0/object'
const isDevMode = env.getVariable('node_env') === 'development'
export class ContentAny {
/**
* @param {Array<any>} arr
*/
constructor (arr) {
/**
* @type {Array<any>}
*/
this.arr = arr
isDevMode && object.deepFreeze(arr)
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentAny}
*/
copy () {
return new ContentAny(this.arr)
}
/**
* @param {number} offset
* @return {ContentAny}
*/
splice (offset) {
const right = new ContentAny(this.arr.slice(offset))
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentAny} right
* @return {boolean}
*/
mergeWith (right) {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoder.writeLen(len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoder.writeAny(c)
}
}
/**
* @return {number}
*/
getRef () {
return 8
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentAny}
*/
export const readContentAny = decoder => {
const len = decoder.readLen()
const cs = []
for (let i = 0; i < len; i++) {
cs.push(decoder.readAny())
}
return new ContentAny(cs)
}

View File

@ -1,92 +0,0 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
export class ContentBinary {
/**
* @param {Uint8Array} content
*/
constructor (content) {
this.content = content
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.content]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentBinary}
*/
copy () {
return new ContentBinary(this.content)
}
/**
* @param {number} offset
* @return {ContentBinary}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentBinary} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeBuf(this.content)
}
/**
* @return {number}
*/
getRef () {
return 3
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @return {ContentBinary}
*/
export const readContentBinary = decoder => new ContentBinary(decoder.readBuf())

View File

@ -1,100 +0,0 @@
import {
addToDeleteSet,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
export class ContentDeleted {
/**
* @param {number} len
*/
constructor (len) {
this.len = len
}
/**
* @return {number}
*/
getLength () {
return this.len
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentDeleted}
*/
copy () {
return new ContentDeleted(this.len)
}
/**
* @param {number} offset
* @return {ContentDeleted}
*/
splice (offset) {
const right = new ContentDeleted(this.len - offset)
this.len = offset
return right
}
/**
* @param {ContentDeleted} right
* @return {boolean}
*/
mergeWith (right) {
this.len += right.len
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
addToDeleteSet(transaction.deleteSet, item.id.client, item.id.clock, this.len)
item.markDeleted()
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeLen(this.len - offset)
}
/**
* @return {number}
*/
getRef () {
return 1
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder
* @return {ContentDeleted}
*/
export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen())

View File

@ -1,140 +0,0 @@
import {
Doc, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @param {string} guid
* @param {Object<string, any>} opts
*/
const createDocFromOpts = (guid, opts) => new Doc({ guid, ...opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false })
/**
* @private
*/
export class ContentDoc {
/**
* @param {Doc} doc
*/
constructor (doc) {
if (doc._item) {
console.error('This document was already integrated as a sub-document. You should create a second instance instead with the same guid.')
}
/**
* @type {Doc}
*/
this.doc = doc
/**
* @type {any}
*/
const opts = {}
this.opts = opts
if (!doc.gc) {
opts.gc = false
}
if (doc.autoLoad) {
opts.autoLoad = true
}
if (doc.meta !== null) {
opts.meta = doc.meta
}
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.doc]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentDoc}
*/
copy () {
return new ContentDoc(createDocFromOpts(this.doc.guid, this.opts))
}
/**
* @param {number} offset
* @return {ContentDoc}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentDoc} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
// this needs to be reflected in doc.destroy as well
this.doc._item = item
transaction.subdocsAdded.add(this.doc)
if (this.doc.shouldLoad) {
transaction.subdocsLoaded.add(this.doc)
}
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
if (transaction.subdocsAdded.has(this.doc)) {
transaction.subdocsAdded.delete(this.doc)
} else {
transaction.subdocsRemoved.add(this.doc)
}
}
/**
* @param {StructStore} store
*/
gc (store) { }
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeString(this.doc.guid)
encoder.writeAny(this.opts)
}
/**
* @return {number}
*/
getRef () {
return 9
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentDoc}
*/
export const readContentDoc = decoder => new ContentDoc(createDocFromOpts(decoder.readString(), decoder.readAny()))

View File

@ -1,97 +0,0 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Item, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @private
*/
export class ContentEmbed {
/**
* @param {Object} embed
*/
constructor (embed) {
this.embed = embed
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.embed]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentEmbed}
*/
copy () {
return new ContentEmbed(this.embed)
}
/**
* @param {number} offset
* @return {ContentEmbed}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentEmbed} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeJSON(this.embed)
}
/**
* @return {number}
*/
getRef () {
return 5
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentEmbed}
*/
export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON())

View File

@ -1,104 +0,0 @@
import {
YText, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Item, StructStore, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @private
*/
export class ContentFormat {
/**
* @param {string} key
* @param {Object} value
*/
constructor (key, value) {
this.key = key
this.value = value
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return []
}
/**
* @return {boolean}
*/
isCountable () {
return false
}
/**
* @return {ContentFormat}
*/
copy () {
return new ContentFormat(this.key, this.value)
}
/**
* @param {number} _offset
* @return {ContentFormat}
*/
splice (_offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentFormat} _right
* @return {boolean}
*/
mergeWith (_right) {
return false
}
/**
* @param {Transaction} _transaction
* @param {Item} item
*/
integrate (_transaction, item) {
// @todo searchmarker are currently unsupported for rich text documents
const p = /** @type {YText} */ (item.parent)
p._searchMarker = null
p._hasFormatting = true
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeKey(this.key)
encoder.writeJSON(this.value)
}
/**
* @return {number}
*/
getRef () {
return 6
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentFormat}
*/
export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON())

View File

@ -1,118 +0,0 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
/**
* @private
*/
export class ContentJSON {
/**
* @param {Array<any>} arr
*/
constructor (arr) {
/**
* @type {Array<any>}
*/
this.arr = arr
}
/**
* @return {number}
*/
getLength () {
return this.arr.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.arr
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentJSON}
*/
copy () {
return new ContentJSON(this.arr)
}
/**
* @param {number} offset
* @return {ContentJSON}
*/
splice (offset) {
const right = new ContentJSON(this.arr.slice(offset))
this.arr = this.arr.slice(0, offset)
return right
}
/**
* @param {ContentJSON} right
* @return {boolean}
*/
mergeWith (right) {
this.arr = this.arr.concat(right.arr)
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
const len = this.arr.length
encoder.writeLen(len - offset)
for (let i = offset; i < len; i++) {
const c = this.arr[i]
encoder.writeString(c === undefined ? 'undefined' : JSON.stringify(c))
}
}
/**
* @return {number}
*/
getRef () {
return 2
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentJSON}
*/
export const readContentJSON = decoder => {
const len = decoder.readLen()
const cs = []
for (let i = 0; i < len; i++) {
const c = decoder.readString()
if (c === 'undefined') {
cs.push(undefined)
} else {
cs.push(JSON.parse(c))
}
}
return new ContentJSON(cs)
}

View File

@ -1,112 +0,0 @@
import {
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Transaction, Item, StructStore // eslint-disable-line
} from '../internals.js'
/**
* @private
*/
export class ContentString {
/**
* @param {string} str
*/
constructor (str) {
/**
* @type {string}
*/
this.str = str
}
/**
* @return {number}
*/
getLength () {
return this.str.length
}
/**
* @return {Array<any>}
*/
getContent () {
return this.str.split('')
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentString}
*/
copy () {
return new ContentString(this.str)
}
/**
* @param {number} offset
* @return {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
}
/**
* @param {ContentString} right
* @return {boolean}
*/
mergeWith (right) {
this.str += right.str
return true
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {}
/**
* @param {Transaction} transaction
*/
delete (transaction) {}
/**
* @param {StructStore} store
*/
gc (store) {}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeString(offset === 0 ? this.str : this.str.slice(offset))
}
/**
* @return {number}
*/
getRef () {
return 4
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentString}
*/
export const readContentString = decoder => new ContentString(decoder.readString())

View File

@ -1,171 +0,0 @@
import {
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, Item, YEvent, AbstractType // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
/**
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractType<any>>}
* @private
*/
export const typeRefs = [
readYArray,
readYMap,
readYText,
readYXmlElement,
readYXmlFragment,
readYXmlHook,
readYXmlText
]
export const YArrayRefID = 0
export const YMapRefID = 1
export const YTextRefID = 2
export const YXmlElementRefID = 3
export const YXmlFragmentRefID = 4
export const YXmlHookRefID = 5
export const YXmlTextRefID = 6
/**
* @private
*/
export class ContentType {
/**
* @param {AbstractType<any>} type
*/
constructor (type) {
/**
* @type {AbstractType<any>}
*/
this.type = type
}
/**
* @return {number}
*/
getLength () {
return 1
}
/**
* @return {Array<any>}
*/
getContent () {
return [this.type]
}
/**
* @return {boolean}
*/
isCountable () {
return true
}
/**
* @return {ContentType}
*/
copy () {
return new ContentType(this.type._copy())
}
/**
* @param {number} offset
* @return {ContentType}
*/
splice (offset) {
throw error.methodUnimplemented()
}
/**
* @param {ContentType} right
* @return {boolean}
*/
mergeWith (right) {
return false
}
/**
* @param {Transaction} transaction
* @param {Item} item
*/
integrate (transaction, item) {
this.type._integrate(transaction.doc, item)
}
/**
* @param {Transaction} transaction
*/
delete (transaction) {
let item = this.type._start
while (item !== null) {
if (!item.deleted) {
item.delete(transaction)
} else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) {
// This will be gc'd later and we want to merge it if possible
// We try to merge all deleted items after each transaction,
// but we have no knowledge about that this needs to be merged
// since it is not in transaction.ds. Hence we add it to transaction._mergeStructs
transaction._mergeStructs.push(item)
}
item = item.right
}
this.type._map.forEach(item => {
if (!item.deleted) {
item.delete(transaction)
} else if (item.id.clock < (transaction.beforeState.get(item.id.client) || 0)) {
// same as above
transaction._mergeStructs.push(item)
}
})
transaction.changed.delete(this.type)
}
/**
* @param {StructStore} store
*/
gc (store) {
let item = this.type._start
while (item !== null) {
item.gc(store, true)
item = item.right
}
this.type._start = null
this.type._map.forEach(/** @param {Item | null} item */ (item) => {
while (item !== null) {
item.gc(store, true)
item = item.left
}
})
this.type._map = new Map()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
this.type._write(encoder)
}
/**
* @return {number}
*/
getRef () {
return 7
}
}
/**
* @private
*
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {ContentType}
*/
export const readContentType = decoder => new ContentType(typeRefs[decoder.readTypeRef()](decoder))

View File

@ -1,60 +0,0 @@
import {
AbstractStruct,
addStruct,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
export const structGCRefNumber = 0
/**
* @private
*/
export class GC extends AbstractStruct {
get deleted () {
return true
}
delete () {}
/**
* @param {GC} right
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.length -= offset
}
addStruct(transaction.doc.store, this)
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeInfo(structGCRefNumber)
encoder.writeLen(this.length - offset)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | number}
*/
getMissing (transaction, store) {
return null
}
}

View File

@ -1,812 +0,0 @@
import {
GC,
getState,
AbstractStruct,
replaceStruct,
addStruct,
addToDeleteSet,
findRootTypeKey,
compareIDs,
getItem,
getItemCleanEnd,
getItemCleanStart,
readContentDeleted,
readContentBinary,
readContentJSON,
readContentAny,
readContentString,
readContentEmbed,
readContentDoc,
createID,
readContentFormat,
readContentType,
addChangedTypeToTransaction,
isDeleted,
StackItem, DeleteSet, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, ContentType, ContentDeleted, StructStore, ID, AbstractType, Transaction // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as binary from 'lib0/binary'
import * as array from 'lib0/array'
/**
* @todo This should return several items
*
* @param {StructStore} store
* @param {ID} id
* @return {{item:Item, diff:number}}
*/
export const followRedone = (store, id) => {
/**
* @type {ID|null}
*/
let nextID = id
let diff = 0
let item
do {
if (diff > 0) {
nextID = createID(nextID.client, nextID.clock + diff)
}
item = getItem(store, nextID)
diff = nextID.clock - item.id.clock
nextID = item.redone
} while (nextID !== null && item instanceof Item)
return {
item, diff
}
}
/**
* Make sure that neither item nor any of its parents is ever deleted.
*
* This property does not persist when storing it into a database or when
* sending it to other peers
*
* @param {Item|null} item
* @param {boolean} keep
*/
export const keepItem = (item, keep) => {
while (item !== null && item.keep !== keep) {
item.keep = keep
item = /** @type {AbstractType<any>} */ (item.parent)._item
}
}
/**
* Split leftItem into two items
* @param {Transaction} transaction
* @param {Item} leftItem
* @param {number} diff
* @return {Item}
*
* @function
* @private
*/
export const splitItem = (transaction, leftItem, diff) => {
// create rightItem
const { client, clock } = leftItem.id
const rightItem = new Item(
createID(client, clock + diff),
leftItem,
createID(client, clock + diff - 1),
leftItem.right,
leftItem.rightOrigin,
leftItem.parent,
leftItem.parentSub,
leftItem.content.splice(diff)
)
if (leftItem.deleted) {
rightItem.markDeleted()
}
if (leftItem.keep) {
rightItem.keep = true
}
if (leftItem.redone !== null) {
rightItem.redone = createID(leftItem.redone.client, leftItem.redone.clock + diff)
}
// update left (do not set leftItem.rightOrigin as it will lead to problems when syncing)
leftItem.right = rightItem
// update right
if (rightItem.right !== null) {
rightItem.right.left = rightItem
}
// right is more specific.
transaction._mergeStructs.push(rightItem)
// update parent._map
if (rightItem.parentSub !== null && rightItem.right === null) {
/** @type {AbstractType<any>} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem)
}
leftItem.length = diff
return rightItem
}
/**
* @param {Array<StackItem>} stack
* @param {ID} id
*/
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => isDeleted(s.deletions, id))
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
* @param {DeleteSet} itemsToDelete
* @param {boolean} ignoreRemoteMapChanges
* @param {import('../utils/UndoManager.js').UndoManager} um
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) => {
const doc = transaction.doc
const store = doc.store
const ownClientID = doc.clientID
const redone = item.redone
if (redone !== null) {
return getItemCleanStart(transaction, redone)
}
let parentItem = /** @type {AbstractType<any>} */ (item.parent)._item
/**
* @type {Item|null}
*/
let left = null
/**
* @type {Item|null}
*/
let right
// make sure that parent is redone
if (parentItem !== null && parentItem.deleted === true) {
// try to undo parent if it will be undone anyway
if (parentItem.redone === null && (!redoitems.has(parentItem) || redoItem(transaction, parentItem, redoitems, itemsToDelete, ignoreRemoteMapChanges, um) === null)) {
return null
}
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
}
const parentType = parentItem === null ? /** @type {AbstractType<any>} */ (item.parent) : /** @type {ContentType} */ (parentItem.content).type
if (item.parentSub === null) {
// Is an array item. Insert at the old position
left = item.left
right = item
// find next cloned_redo items
while (left !== null) {
/**
* @type {Item|null}
*/
let leftTrace = left
// trace redone until parent matches
while (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && /** @type {AbstractType<any>} */ (leftTrace.parent)._item === parentItem) {
left = leftTrace
break
}
left = left.left
}
while (right !== null) {
/**
* @type {Item|null}
*/
let rightTrace = right
// trace redone until parent matches
while (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && /** @type {AbstractType<any>} */ (rightTrace.parent)._item === parentItem) {
right = rightTrace
break
}
right = right.right
}
} else {
right = null
if (item.right && !ignoreRemoteMapChanges) {
left = item
// Iterate right while right is in itemsToDelete
// If it is intended to delete right while item is redone, we can expect that item should replace right.
while (left !== null && left.right !== null && (left.right.redone || isDeleted(itemsToDelete, left.right.id) || isDeletedByUndoStack(um.undoStack, left.right.id) || isDeletedByUndoStack(um.redoStack, left.right.id))) {
left = left.right
// follow redone
while (left.redone) left = getItemCleanStart(transaction, left.redone)
}
if (left && left.right !== null) {
// It is not possible to redo this item because it conflicts with a
// change from another client
return null
}
} else {
left = parentType._map.get(item.parentSub) || null
}
}
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)
const redoneItem = new Item(
nextId,
left, left && left.lastId,
right, right && right.id,
parentType,
item.parentSub,
item.content.copy()
)
item.redone = nextId
keepItem(redoneItem, true)
redoneItem.integrate(transaction, 0)
return redoneItem
}
/**
* Abstract class that represents any content.
*/
export class Item extends AbstractStruct {
/**
* @param {ID} id
* @param {Item | null} left
* @param {ID | null} origin
* @param {Item | null} right
* @param {ID | null} rightOrigin
* @param {AbstractType<any>|ID|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it.
* @param {string | null} parentSub
* @param {AbstractContent} content
*/
constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) {
super(id, content.getLength())
/**
* The item that was originally to the left of this item.
* @type {ID | null}
*/
this.origin = origin
/**
* The item that is currently to the left of this item.
* @type {Item | null}
*/
this.left = left
/**
* The item that is currently to the right of this item.
* @type {Item | null}
*/
this.right = right
/**
* The item that was originally to the right of this item.
* @type {ID | null}
*/
this.rightOrigin = rightOrigin
/**
* @type {AbstractType<any>|ID|null}
*/
this.parent = parent
/**
* If the parent refers to this item with some kind of key (e.g. YMap, the
* key is specified here. The key is then used to refer to the list in which
* to insert this item. If `parentSub = null` type._start is the list in
* which to insert to. Otherwise it is `parent._map`.
* @type {String | null}
*/
this.parentSub = parentSub
/**
* If this type's effect is redone this type refers to the type that undid
* this operation.
* @type {ID | null}
*/
this.redone = null
/**
* @type {AbstractContent}
*/
this.content = content
/**
* bit1: keep
* bit2: countable
* bit3: deleted
* bit4: mark - mark node as fast-search-marker
* @type {number} byte
*/
this.info = this.content.isCountable() ? binary.BIT2 : 0
}
/**
* This is used to mark the item as an indexed fast-search marker
*
* @type {boolean}
*/
set marker (isMarked) {
if (((this.info & binary.BIT4) > 0) !== isMarked) {
this.info ^= binary.BIT4
}
}
get marker () {
return (this.info & binary.BIT4) > 0
}
/**
* If true, do not garbage collect this Item.
*/
get keep () {
return (this.info & binary.BIT1) > 0
}
set keep (doKeep) {
if (this.keep !== doKeep) {
this.info ^= binary.BIT1
}
}
get countable () {
return (this.info & binary.BIT2) > 0
}
/**
* Whether this item was deleted or not.
* @type {Boolean}
*/
get deleted () {
return (this.info & binary.BIT3) > 0
}
set deleted (doDelete) {
if (this.deleted !== doDelete) {
this.info ^= binary.BIT3
}
}
markDeleted () {
this.info |= binary.BIT3
}
/**
* Return the creator clientID of the missing op or define missing items and return null.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | number}
*/
getMissing (transaction, store) {
if (this.origin && this.origin.client !== this.id.client && this.origin.clock >= getState(store, this.origin.client)) {
return this.origin.client
}
if (this.rightOrigin && this.rightOrigin.client !== this.id.client && this.rightOrigin.clock >= getState(store, this.rightOrigin.client)) {
return this.rightOrigin.client
}
if (this.parent && this.parent.constructor === ID && this.id.client !== this.parent.client && this.parent.clock >= getState(store, this.parent.client)) {
return this.parent.client
}
// We have all missing ids, now find the items
if (this.origin) {
this.left = getItemCleanEnd(transaction, store, this.origin)
this.origin = this.left.lastId
}
if (this.rightOrigin) {
this.right = getItemCleanStart(transaction, this.rightOrigin)
this.rightOrigin = this.right.id
}
if ((this.left && this.left.constructor === GC) || (this.right && this.right.constructor === GC)) {
this.parent = null
} else if (!this.parent) {
// only set parent if this shouldn't be garbage collected
if (this.left && this.left.constructor === Item) {
this.parent = this.left.parent
this.parentSub = this.left.parentSub
} else if (this.right && this.right.constructor === Item) {
this.parent = this.right.parent
this.parentSub = this.right.parentSub
}
} else if (this.parent.constructor === ID) {
const parentItem = getItem(store, this.parent)
if (parentItem.constructor === GC) {
this.parent = null
} else {
this.parent = /** @type {ContentType} */ (parentItem.content).type
}
}
return null
}
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1))
this.origin = this.left.lastId
this.content = this.content.splice(offset)
this.length -= offset
}
if (this.parent) {
if ((!this.left && (!this.right || this.right.left !== null)) || (this.left && this.left.right !== this.right)) {
/**
* @type {Item|null}
*/
let left = this.left
/**
* @type {Item|null}
*/
let o
// set o to the first conflicting item
if (left !== null) {
o = left.right
} else if (this.parentSub !== null) {
o = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
while (o !== null && o.left !== null) {
o = o.left
}
} else {
o = /** @type {AbstractType<any>} */ (this.parent)._start
}
// TODO: use something like DeleteSet here (a tree implementation would be best)
// @todo use global set definitions
/**
* @type {Set<Item>}
*/
const conflictingItems = new Set()
/**
* @type {Set<Item>}
*/
const itemsBeforeOrigin = new Set()
// Let c in conflictingItems, b in itemsBeforeOrigin
// ***{origin}bbbb{this}{c,b}{c,b}{o}***
// Note that conflictingItems is a subset of itemsBeforeOrigin
while (o !== null && o !== this.right) {
itemsBeforeOrigin.add(o)
conflictingItems.add(o)
if (compareIDs(this.origin, o.origin)) {
// case 1
if (o.id.client < this.id.client) {
left = o
conflictingItems.clear()
} else if (compareIDs(this.rightOrigin, o.rightOrigin)) {
// this and o are conflicting and point to the same integration points. The id decides which item comes first.
// Since this is to the left of o, we can break here
break
} // else, o might be integrated before an item that this conflicts with. If so, we will find it in the next iterations
} else if (o.origin !== null && itemsBeforeOrigin.has(getItem(transaction.doc.store, o.origin))) { // use getItem instead of getItemCleanEnd because we don't want / need to split items.
// case 2
if (!conflictingItems.has(getItem(transaction.doc.store, o.origin))) {
left = o
conflictingItems.clear()
}
} else {
break
}
o = o.right
}
this.left = left
}
// reconnect left/right + update parent map/start if necessary
if (this.left !== null) {
const right = this.left.right
this.right = right
this.left.right = this
} else {
let r
if (this.parentSub !== null) {
r = /** @type {AbstractType<any>} */ (this.parent)._map.get(this.parentSub) || null
while (r !== null && r.left !== null) {
r = r.left
}
} else {
r = /** @type {AbstractType<any>} */ (this.parent)._start
;/** @type {AbstractType<any>} */ (this.parent)._start = this
}
this.right = r
}
if (this.right !== null) {
this.right.left = this
} else if (this.parentSub !== null) {
// set as current parent value if right === null and this is parentSub
/** @type {AbstractType<any>} */ (this.parent)._map.set(this.parentSub, this)
if (this.left !== null) {
// this is the current attribute value of parent. delete right
this.left.delete(transaction)
}
}
// adjust length of parent
if (this.parentSub === null && this.countable && !this.deleted) {
/** @type {AbstractType<any>} */ (this.parent)._length += this.length
}
addStruct(transaction.doc.store, this)
this.content.integrate(transaction, this)
// add parent to transaction.changed
addChangedTypeToTransaction(transaction, /** @type {AbstractType<any>} */ (this.parent), this.parentSub)
if ((/** @type {AbstractType<any>} */ (this.parent)._item !== null && /** @type {AbstractType<any>} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) {
// delete if parent is deleted or if this is not the current attribute value of parent
this.delete(transaction)
}
} else {
// parent is not defined. Integrate GC struct instead
new GC(this.id, this.length).integrate(transaction, 0)
}
}
/**
* Returns the next non-deleted item
*/
get next () {
let n = this.right
while (n !== null && n.deleted) {
n = n.right
}
return n
}
/**
* Returns the previous non-deleted item
*/
get prev () {
let n = this.left
while (n !== null && n.deleted) {
n = n.left
}
return n
}
/**
* Computes the last content address of this Item.
*/
get lastId () {
// allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible
return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1)
}
/**
* Try to merge two items
*
* @param {Item} right
* @return {boolean}
*/
mergeWith (right) {
if (
this.constructor === right.constructor &&
compareIDs(right.origin, this.lastId) &&
this.right === right &&
compareIDs(this.rightOrigin, right.rightOrigin) &&
this.id.client === right.id.client &&
this.id.clock + this.length === right.id.clock &&
this.deleted === right.deleted &&
this.redone === null &&
right.redone === null &&
this.content.constructor === right.content.constructor &&
this.content.mergeWith(right.content)
) {
const searchMarker = /** @type {AbstractType<any>} */ (this.parent)._searchMarker
if (searchMarker) {
searchMarker.forEach(marker => {
if (marker.p === right) {
// right is going to be "forgotten" so we need to update the marker
marker.p = this
// adjust marker index
if (!this.deleted && this.countable) {
marker.index -= this.length
}
}
})
}
if (right.keep) {
this.keep = true
}
this.right = right.right
if (this.right !== null) {
this.right.left = this
}
this.length += right.length
return true
}
return false
}
/**
* Mark this Item as deleted.
*
* @param {Transaction} transaction
*/
delete (transaction) {
if (!this.deleted) {
const parent = /** @type {AbstractType<any>} */ (this.parent)
// adjust the length of parent
if (this.countable && this.parentSub === null) {
parent._length -= this.length
}
this.markDeleted()
addToDeleteSet(transaction.deleteSet, this.id.client, this.id.clock, this.length)
addChangedTypeToTransaction(transaction, parent, this.parentSub)
this.content.delete(transaction)
}
}
/**
* @param {StructStore} store
* @param {boolean} parentGCd
*/
gc (store, parentGCd) {
if (!this.deleted) {
throw error.unexpectedCase()
}
this.content.gc(store)
if (parentGCd) {
replaceStruct(store, this, new GC(this.id, this.length))
} else {
this.content = new ContentDeleted(this.length)
}
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
* @param {number} offset
*/
write (encoder, offset) {
const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin
const rightOrigin = this.rightOrigin
const parentSub = this.parentSub
const info = (this.content.getRef() & binary.BITS5) |
(origin === null ? 0 : binary.BIT8) | // origin is defined
(rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined
(parentSub === null ? 0 : binary.BIT6) // parentSub is non-null
encoder.writeInfo(info)
if (origin !== null) {
encoder.writeLeftID(origin)
}
if (rightOrigin !== null) {
encoder.writeRightID(rightOrigin)
}
if (origin === null && rightOrigin === null) {
const parent = /** @type {AbstractType<any>} */ (this.parent)
if (parent._item !== undefined) {
const parentItem = parent._item
if (parentItem === null) {
// parent type on y._map
// find the correct key
const ykey = findRootTypeKey(parent)
encoder.writeParentInfo(true) // write parentYKey
encoder.writeString(ykey)
} else {
encoder.writeParentInfo(false) // write parent id
encoder.writeLeftID(parentItem.id)
}
} else if (parent.constructor === String) { // this edge case was added by differential updates
encoder.writeParentInfo(true) // write parentYKey
encoder.writeString(parent)
} else if (parent.constructor === ID) {
encoder.writeParentInfo(false) // write parent id
encoder.writeLeftID(parent)
} else {
error.unexpectedCase()
}
if (parentSub !== null) {
encoder.writeString(parentSub)
}
}
this.content.write(encoder, offset)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {number} info
*/
export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder)
/**
* A lookup map for reading Item content.
*
* @type {Array<function(UpdateDecoderV1 | UpdateDecoderV2):AbstractContent>}
*/
export const contentRefs = [
() => { error.unexpectedCase() }, // GC is not ItemContent
readContentDeleted, // 1
readContentJSON, // 2
readContentBinary, // 3
readContentString, // 4
readContentEmbed, // 5
readContentFormat, // 6
readContentType, // 7
readContentAny, // 8
readContentDoc, // 9
() => { error.unexpectedCase() } // 10 - Skip is not ItemContent
]
/**
* Do not implement this class!
*/
export class AbstractContent {
/**
* @return {number}
*/
getLength () {
throw error.methodUnimplemented()
}
/**
* @return {Array<any>}
*/
getContent () {
throw error.methodUnimplemented()
}
/**
* Should return false if this Item is some kind of meta information
* (e.g. format information).
*
* * Whether this Item should be addressable via `yarray.get(i)`
* * Whether this Item should be counted when computing yarray.length
*
* @return {boolean}
*/
isCountable () {
throw error.methodUnimplemented()
}
/**
* @return {AbstractContent}
*/
copy () {
throw error.methodUnimplemented()
}
/**
* @param {number} _offset
* @return {AbstractContent}
*/
splice (_offset) {
throw error.methodUnimplemented()
}
/**
* @param {AbstractContent} _right
* @return {boolean}
*/
mergeWith (_right) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} _transaction
* @param {Item} _item
*/
integrate (_transaction, _item) {
throw error.methodUnimplemented()
}
/**
* @param {Transaction} _transaction
*/
delete (_transaction) {
throw error.methodUnimplemented()
}
/**
* @param {StructStore} _store
*/
gc (_store) {
throw error.methodUnimplemented()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder
* @param {number} _offset
*/
write (_encoder, _offset) {
throw error.methodUnimplemented()
}
/**
* @return {number}
*/
getRef () {
throw error.methodUnimplemented()
}
}

View File

@ -1,59 +0,0 @@
import {
AbstractStruct,
UpdateEncoderV1, UpdateEncoderV2, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding'
export const structSkipRefNumber = 10
/**
* @private
*/
export class Skip extends AbstractStruct {
get deleted () {
return true
}
delete () {}
/**
* @param {Skip} right
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
/**
* @param {Transaction} transaction
* @param {number} offset
*/
integrate (transaction, offset) {
// skip structs cannot be integrated
error.unexpectedCase()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
*/
write (encoder, offset) {
encoder.writeInfo(structSkipRefNumber)
// write as VarUint because Skips can't make use of predictable length-encoding
encoding.writeVarUint(encoder.restEncoder, this.length - offset)
}
/**
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | number}
*/
getMissing (transaction, store) {
return null
}
}

View File

@ -1,983 +0,0 @@
import {
removeEventHandlerListener,
callEventHandlerListeners,
addEventHandlerListener,
createEventHandler,
getState,
isVisible,
ContentType,
createID,
ContentAny,
ContentBinary,
getItemCleanStart,
ContentDoc, YText, YArray, UpdateEncoderV1, UpdateEncoderV2, Doc, Snapshot, Transaction, EventHandler, YEvent, Item, // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
import * as iterator from 'lib0/iterator'
import * as error from 'lib0/error'
import * as math from 'lib0/math'
import * as log from 'lib0/logging'
/**
* https://docs.yjs.dev/getting-started/working-with-shared-types#caveats
*/
export const warnPrematureAccess = () => { log.warn('Invalid access: Add Yjs type to a document before reading data.') }
const maxSearchMarker = 80
/**
* A unique timestamp that identifies each marker.
*
* Time is relative,.. this is more like an ever-increasing clock.
*
* @type {number}
*/
let globalSearchMarkerTimestamp = 0
export class ArraySearchMarker {
/**
* @param {Item} p
* @param {number} index
*/
constructor (p, index) {
p.marker = true
this.p = p
this.index = index
this.timestamp = globalSearchMarkerTimestamp++
}
}
/**
* @param {ArraySearchMarker} marker
*/
const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ }
/**
* This is rather complex so this function is the only thing that should overwrite a marker
*
* @param {ArraySearchMarker} marker
* @param {Item} p
* @param {number} index
*/
const overwriteMarker = (marker, p, index) => {
marker.p.marker = false
marker.p = p
p.marker = true
marker.index = index
marker.timestamp = globalSearchMarkerTimestamp++
}
/**
* @param {Array<ArraySearchMarker>} searchMarker
* @param {Item} p
* @param {number} index
*/
const markPosition = (searchMarker, p, index) => {
if (searchMarker.length >= maxSearchMarker) {
// override oldest marker (we don't want to create more objects)
const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b)
overwriteMarker(marker, p, index)
return marker
} else {
// create new marker
const pm = new ArraySearchMarker(p, index)
searchMarker.push(pm)
return pm
}
}
/**
* Search marker help us to find positions in the associative array faster.
*
* They speed up the process of finding a position without much bookkeeping.
*
* A maximum of `maxSearchMarker` objects are created.
*
* This function always returns a refreshed marker (updated timestamp)
*
* @param {AbstractType<any>} yarray
* @param {number} index
*/
export const findMarker = (yarray, index) => {
if (yarray._start === null || index === 0 || yarray._searchMarker === null) {
return null
}
const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b)
let p = yarray._start
let pindex = 0
if (marker !== null) {
p = marker.p
pindex = marker.index
refreshMarkerTimestamp(marker) // we used it, we might need to use it again
}
// iterate to right if possible
while (p.right !== null && pindex < index) {
if (!p.deleted && p.countable) {
if (index < pindex + p.length) {
break
}
pindex += p.length
}
p = p.right
}
// iterate to left if necessary (might be that pindex > index)
while (p.left !== null && pindex > index) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// we want to make sure that p can't be merged with left, because that would screw up everything
// in that cas just return what we have (it is most likely the best marker anyway)
// iterate to left until p can't be merged with left
while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) {
p = p.left
if (!p.deleted && p.countable) {
pindex -= p.length
}
}
// @todo remove!
// assure position
// {
// let start = yarray._start
// let pos = 0
// while (start !== p) {
// if (!start.deleted && start.countable) {
// pos += start.length
// }
// start = /** @type {Item} */ (start.right)
// }
// if (pos !== pindex) {
// debugger
// throw new Error('Gotcha position fail!')
// }
// }
// if (marker) {
// if (window.lengths == null) {
// window.lengths = []
// window.getLengths = () => window.lengths.sort((a, b) => a - b)
// }
// window.lengths.push(marker.index - pindex)
// console.log('distance', marker.index - pindex, 'len', p && p.parent.length)
// }
if (marker !== null && math.abs(marker.index - pindex) < /** @type {YText|YArray<any>} */ (p.parent).length / maxSearchMarker) {
// adjust existing marker
overwriteMarker(marker, p, pindex)
return marker
} else {
// create new marker
return markPosition(yarray._searchMarker, p, pindex)
}
}
/**
* Update markers when a change happened.
*
* This should be called before doing a deletion!
*
* @param {Array<ArraySearchMarker>} searchMarker
* @param {number} index
* @param {number} len If insertion, len is positive. If deletion, len is negative.
*/
export const updateMarkerChanges = (searchMarker, index, len) => {
for (let i = searchMarker.length - 1; i >= 0; i--) {
const m = searchMarker[i]
if (len > 0) {
/**
* @type {Item|null}
*/
let p = m.p
p.marker = false
// Ideally we just want to do a simple position comparison, but this will only work if
// search markers don't point to deleted items for formats.
// Iterate marker to prev undeleted countable position so we know what to do when updating a position
while (p && (p.deleted || !p.countable)) {
p = p.left
if (p && !p.deleted && p.countable) {
// adjust position. the loop should break now
m.index -= p.length
}
}
if (p === null || p.marker === true) {
// remove search marker if updated position is null or if position is already marked
searchMarker.splice(i, 1)
continue
}
m.p = p
p.marker = true
}
if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice
m.index = math.max(index, m.index + len)
}
}
}
/**
* Accumulate all (list) children of a type and return them as an Array.
*
* @param {AbstractType<any>} t
* @return {Array<Item>}
*/
export const getTypeChildren = t => {
t.doc ?? warnPrematureAccess()
let s = t._start
const arr = []
while (s) {
arr.push(s)
s = s.right
}
return arr
}
/**
* Call event listeners with an event. This will also add an event to all
* parents (for `.observeDeep` handlers).
*
* @template EventType
* @param {AbstractType<EventType>} type
* @param {Transaction} transaction
* @param {EventType} event
*/
export const callTypeObservers = (type, transaction, event) => {
const changedType = type
const changedParentTypes = transaction.changedParentTypes
while (true) {
// @ts-ignore
map.setIfUndefined(changedParentTypes, type, () => []).push(event)
if (type._item === null) {
break
}
type = /** @type {AbstractType<any>} */ (type._item.parent)
}
callEventHandlerListeners(changedType._eH, event, transaction)
}
/**
* @template EventType
* Abstract Yjs Type class
*/
export class AbstractType {
constructor () {
/**
* @type {Item|null}
*/
this._item = null
/**
* @type {Map<string,Item>}
*/
this._map = new Map()
/**
* @type {Item|null}
*/
this._start = null
/**
* @type {Doc|null}
*/
this.doc = null
this._length = 0
/**
* Event handlers
* @type {EventHandler<EventType,Transaction>}
*/
this._eH = createEventHandler()
/**
* Deep event handlers
* @type {EventHandler<Array<YEvent<any>>,Transaction>}
*/
this._dEH = createEventHandler()
/**
* @type {null | Array<ArraySearchMarker>}
*/
this._searchMarker = null
}
/**
* @return {AbstractType<any>|null}
*/
get parent () {
return this._item ? /** @type {AbstractType<any>} */ (this._item.parent) : null
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item|null} item
*/
_integrate (y, item) {
this.doc = y
this._item = item
}
/**
* @return {AbstractType<EventType>}
*/
_copy () {
throw error.methodUnimplemented()
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {AbstractType<EventType>}
*/
clone () {
throw error.methodUnimplemented()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder
*/
_write (_encoder) { }
/**
* The first non-deleted item
*/
get _first () {
let n = this._start
while (n !== null && n.deleted) {
n = n.right
}
return n
}
/**
* Creates YEvent and calls all type observers.
* Must be implemented by each type.
*
* @param {Transaction} transaction
* @param {Set<null|string>} _parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, _parentSubs) {
if (!transaction.local && this._searchMarker) {
this._searchMarker.length = 0
}
}
/**
* Observe all events that are created on this type.
*
* @param {function(EventType, Transaction):void} f Observer function
*/
observe (f) {
addEventHandlerListener(this._eH, f)
}
/**
* Observe all events that are created by this type and its children.
*
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
*/
observeDeep (f) {
addEventHandlerListener(this._dEH, f)
}
/**
* Unregister an observer function.
*
* @param {function(EventType,Transaction):void} f Observer function
*/
unobserve (f) {
removeEventHandlerListener(this._eH, f)
}
/**
* Unregister an observer function.
*
* @param {function(Array<YEvent<any>>,Transaction):void} f Observer function
*/
unobserveDeep (f) {
removeEventHandlerListener(this._dEH, f)
}
/**
* @abstract
* @return {any}
*/
toJSON () {}
}
/**
* @param {AbstractType<any>} type
* @param {number} start
* @param {number} end
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListSlice = (type, start, end) => {
type.doc ?? warnPrematureAccess()
if (start < 0) {
start = type._length + start
}
if (end < 0) {
end = type._length + end
}
let len = end - start
const cs = []
let n = type._start
while (n !== null && len > 0) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
if (c.length <= start) {
start -= c.length
} else {
for (let i = start; i < c.length && len > 0; i++) {
cs.push(c[i])
len--
}
start = 0
}
}
n = n.right
}
return cs
}
/**
* @param {AbstractType<any>} type
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArray = type => {
type.doc ?? warnPrematureAccess()
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* @param {AbstractType<any>} type
* @param {Snapshot} snapshot
* @return {Array<any>}
*
* @private
* @function
*/
export const typeListToArraySnapshot = (type, snapshot) => {
const cs = []
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
cs.push(c[i])
}
}
n = n.right
}
return cs
}
/**
* Executes a provided function on once on every element of this YArray.
*
* @param {AbstractType<any>} type
* @param {function(any,number,any):void} f A function to execute on every element of this YArray.
*
* @private
* @function
*/
export const typeListForEach = (type, f) => {
let index = 0
let n = type._start
type.doc ?? warnPrematureAccess()
while (n !== null) {
if (n.countable && !n.deleted) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @template C,R
* @param {AbstractType<any>} type
* @param {function(C,number,AbstractType<any>):R} f
* @return {Array<R>}
*
* @private
* @function
*/
export const typeListMap = (type, f) => {
/**
* @type {Array<any>}
*/
const result = []
typeListForEach(type, (c, i) => {
result.push(f(c, i, type))
})
return result
}
/**
* @param {AbstractType<any>} type
* @return {IterableIterator<any>}
*
* @private
* @function
*/
export const typeListCreateIterator = type => {
let n = type._start
/**
* @type {Array<any>|null}
*/
let currentContent = null
let currentContentIndex = 0
return {
[Symbol.iterator] () {
return this
},
next: () => {
// find some content
if (currentContent === null) {
while (n !== null && n.deleted) {
n = n.right
}
// check if we reached the end, no need to check currentContent, because it does not exist
if (n === null) {
return {
done: true,
value: undefined
}
}
// we found n, so we can set currentContent
currentContent = n.content.getContent()
currentContentIndex = 0
n = n.right // we used the content of n, now iterate to next
}
const value = currentContent[currentContentIndex++]
// check if we need to empty currentContent
if (currentContent.length <= currentContentIndex) {
currentContent = null
}
return {
done: false,
value
}
}
}
}
/**
* Executes a provided function on once on every element of this YArray.
* Operates on a snapshotted state of the document.
*
* @param {AbstractType<any>} type
* @param {function(any,number,AbstractType<any>):void} f A function to execute on every element of this YArray.
* @param {Snapshot} snapshot
*
* @private
* @function
*/
export const typeListForEachSnapshot = (type, f, snapshot) => {
let index = 0
let n = type._start
while (n !== null) {
if (n.countable && isVisible(n, snapshot)) {
const c = n.content.getContent()
for (let i = 0; i < c.length; i++) {
f(c[i], index++, type)
}
}
n = n.right
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
* @return {any}
*
* @private
* @function
*/
export const typeListGet = (type, index) => {
type.doc ?? warnPrematureAccess()
const marker = findMarker(type, index)
let n = type._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
return n.content.getContent()[index]
}
index -= n.length
}
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Item?} referenceItem
* @param {Array<Object<string,any>|Array<any>|boolean|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => {
let left = referenceItem
const doc = transaction.doc
const ownClientId = doc.clientID
const store = doc.store
const right = referenceItem === null ? parent._start : referenceItem.right
/**
* @type {Array<Object|Array<any>|number|null>}
*/
let jsonContent = []
const packJsonContent = () => {
if (jsonContent.length > 0) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent))
left.integrate(transaction, 0)
jsonContent = []
}
}
content.forEach(c => {
if (c === null) {
jsonContent.push(c)
} else {
switch (c.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
jsonContent.push(c)
break
default:
packJsonContent()
switch (c.constructor) {
case Uint8Array:
case ArrayBuffer:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c))))
left.integrate(transaction, 0)
break
case Doc:
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentDoc(/** @type {Doc} */ (c)))
left.integrate(transaction, 0)
break
default:
if (c instanceof AbstractType) {
left = new Item(createID(ownClientId, getState(store, ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(c))
left.integrate(transaction, 0)
} else {
throw new Error('Unexpected content type in insert operation')
}
}
}
}
})
packJsonContent()
}
const lengthExceeded = () => error.create('Length exceeded!')
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListInsertGenerics = (transaction, parent, index, content) => {
if (index > parent._length) {
throw lengthExceeded()
}
if (index === 0) {
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, index, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, null, content)
}
const startIndex = index
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
// we need to iterate one to the left so that the algorithm works
if (index === 0) {
// @todo refactor this as it actually doesn't consider formats
n = n.prev // important! get the left undeleted item so that we can actually decrease index
index += (n && n.countable && !n.deleted) ? n.length : 0
}
}
for (; n !== null; n = n.right) {
if (!n.deleted && n.countable) {
if (index <= n.length) {
if (index < n.length) {
// insert in-between
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
break
}
index -= n.length
}
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, content.length)
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* Pushing content is special as we generally want to push after the last item. So we don't have to update
* the search marker.
*
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {Array<Object<string,any>|Array<any>|number|null|string|Uint8Array>} content
*
* @private
* @function
*/
export const typeListPushGenerics = (transaction, parent, content) => {
// Use the marker with the highest index and iterate to the right.
const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start })
let n = marker.p
if (n) {
while (n.right) {
n = n.right
}
}
return typeListInsertGenericsAfter(transaction, parent, n, content)
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {number} index
* @param {number} length
*
* @private
* @function
*/
export const typeListDelete = (transaction, parent, index, length) => {
if (length === 0) { return }
const startIndex = index
const startLength = length
const marker = findMarker(parent, index)
let n = parent._start
if (marker !== null) {
n = marker.p
index -= marker.index
}
// compute the first item to be deleted
for (; n !== null && index > 0; n = n.right) {
if (!n.deleted && n.countable) {
if (index < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index))
}
index -= n.length
}
}
// delete all items until done
while (length > 0 && n !== null) {
if (!n.deleted) {
if (length < n.length) {
getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length))
}
n.delete(transaction)
length -= n.length
}
n = n.right
}
if (length > 0) {
throw lengthExceeded()
}
if (parent._searchMarker) {
updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */)
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
*
* @private
* @function
*/
export const typeMapDelete = (transaction, parent, key) => {
const c = parent._map.get(key)
if (c !== undefined) {
c.delete(transaction)
}
}
/**
* @param {Transaction} transaction
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} value
*
* @private
* @function
*/
export const typeMapSet = (transaction, parent, key, value) => {
const left = parent._map.get(key) || null
const doc = transaction.doc
const ownClientId = doc.clientID
let content
if (value == null) {
content = new ContentAny([value])
} else {
switch (value.constructor) {
case Number:
case Object:
case Boolean:
case Array:
case String:
content = new ContentAny([value])
break
case Uint8Array:
content = new ContentBinary(/** @type {Uint8Array} */ (value))
break
case Doc:
content = new ContentDoc(/** @type {Doc} */ (value))
break
default:
if (value instanceof AbstractType) {
content = new ContentType(value)
} else {
throw new Error('Unexpected content type')
}
}
}
new Item(createID(ownClientId, getState(doc.store, ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0)
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
*/
export const typeMapGet = (parent, key) => {
parent.doc ?? warnPrematureAccess()
const val = parent._map.get(key)
return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined
}
/**
* @param {AbstractType<any>} parent
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
*
* @private
* @function
*/
export const typeMapGetAll = (parent) => {
/**
* @type {Object<string,any>}
*/
const res = {}
parent.doc ?? warnPrematureAccess()
parent._map.forEach((value, key) => {
if (!value.deleted) {
res[key] = value.content.getContent()[value.length - 1]
}
})
return res
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @return {boolean}
*
* @private
* @function
*/
export const typeMapHas = (parent, key) => {
parent.doc ?? warnPrematureAccess()
const val = parent._map.get(key)
return val !== undefined && !val.deleted
}
/**
* @param {AbstractType<any>} parent
* @param {string} key
* @param {Snapshot} snapshot
* @return {Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined}
*
* @private
* @function
*/
export const typeMapGetSnapshot = (parent, key, snapshot) => {
let v = parent._map.get(key) || null
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
}
/**
* @param {AbstractType<any>} parent
* @param {Snapshot} snapshot
* @return {Object<string,Object<string,any>|number|null|Array<any>|string|Uint8Array|AbstractType<any>|undefined>}
*
* @private
* @function
*/
export const typeMapGetAllSnapshot = (parent, snapshot) => {
/**
* @type {Object<string,any>}
*/
const res = {}
parent._map.forEach((value, key) => {
/**
* @type {Item|null}
*/
let v = value
while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) {
v = v.left
}
if (v !== null && isVisible(v, snapshot)) {
res[key] = v.content.getContent()[v.length - 1]
}
})
return res
}
/**
* @param {AbstractType<any> & { _map: Map<string, Item> }} type
* @return {IterableIterator<Array<any>>}
*
* @private
* @function
*/
export const createMapIterator = type => {
type.doc ?? warnPrematureAccess()
return iterator.iteratorFilter(type._map.entries(), /** @param {any} entry */ entry => !entry[1].deleted)
}

View File

@ -1,274 +0,0 @@
/**
* @module YArray
*/
import {
YEvent,
AbstractType,
typeListGet,
typeListToArray,
typeListForEach,
typeListCreateIterator,
typeListInsertGenerics,
typeListPushGenerics,
typeListDelete,
typeListMap,
YArrayRefID,
callTypeObservers,
transact,
warnPrematureAccess,
ArraySearchMarker, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import { typeListSlice } from './AbstractType.js'
/**
* Event that describes the changes on a YArray
* @template T
* @extends YEvent<YArray<T>>
*/
export class YArrayEvent extends YEvent {}
/**
* A shared Array implementation.
* @template T
* @extends AbstractType<YArrayEvent<T>>
* @implements {Iterable<T>}
*/
export class YArray extends AbstractType {
constructor () {
super()
/**
* @type {Array<any>?}
* @private
*/
this._prelimContent = []
/**
* @type {Array<ArraySearchMarker>}
*/
this._searchMarker = []
}
/**
* Construct a new YArray containing the specified items.
* @template {Object<string,any>|Array<any>|number|null|string|Uint8Array} T
* @param {Array<T>} items
* @return {YArray<T>}
*/
static from (items) {
/**
* @type {YArray<T>}
*/
const a = new YArray()
a.push(items)
return a
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this._prelimContent = null
}
/**
* @return {YArray<T>}
*/
_copy () {
return new YArray()
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YArray<T>}
*/
clone () {
/**
* @type {YArray<T>}
*/
const arr = new YArray()
arr.insert(0, this.toArray().map(el =>
el instanceof AbstractType ? /** @type {typeof el} */ (el.clone()) : el
))
return arr
}
get length () {
this.doc ?? warnPrematureAccess()
return this._length
}
/**
* Creates YArrayEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
super._callObserver(transaction, parentSubs)
callTypeObservers(this, transaction, new YArrayEvent(this, transaction))
}
/**
* Inserts new content at an index.
*
* Important: This function expects an array of content. Not just a content
* object. The reason for this "weirdness" is that inserting several elements
* is very efficient when it is done as a single operation.
*
* @example
* // Insert character 'a' at position 0
* yarray.insert(0, ['a'])
* // Insert numbers 1, 2 at position 1
* yarray.insert(1, [1, 2])
*
* @param {number} index The index to insert content at.
* @param {Array<T>} content The array of content
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, /** @type {any} */ (content))
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, 0, ...content)
}
}
/**
* Appends content to this YArray.
*
* @param {Array<T>} content Array of content to append.
*
* @todo Use the following implementation in all types.
*/
push (content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListPushGenerics(transaction, this, /** @type {any} */ (content))
})
} else {
/** @type {Array<any>} */ (this._prelimContent).push(...content)
}
}
/**
* Prepends content to this YArray.
*
* @param {Array<T>} content Array of content to prepend.
*/
unshift (content) {
this.insert(0, content)
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} length The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
/** @type {Array<any>} */ (this._prelimContent).splice(index, length)
}
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {T}
*/
get (index) {
return typeListGet(this, index)
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array<T>}
*/
toArray () {
return typeListToArray(this)
}
/**
* Returns a portion of this YArray into a JavaScript Array selected
* from start to end (end not included).
*
* @param {number} [start]
* @param {number} [end]
* @return {Array<T>}
*/
slice (start = 0, end = this.length) {
return typeListSlice(this, start, end)
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Array<any>}
*/
toJSON () {
return this.map(c => c instanceof AbstractType ? c.toJSON() : c)
}
/**
* Returns an Array with the result of calling a provided function on every
* element of this YArray.
*
* @template M
* @param {function(T,number,YArray<T>):M} f Function that produces an element of the new Array
* @return {Array<M>} A new array with each element being the result of the
* callback function
*/
map (f) {
return typeListMap(this, /** @type {any} */ (f))
}
/**
* Executes a provided function once on every element of this YArray.
*
* @param {function(T,number,YArray<T>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
typeListForEach(this, f)
}
/**
* @return {IterableIterator<T>}
*/
[Symbol.iterator] () {
return typeListCreateIterator(this)
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YArrayRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
*
* @private
* @function
*/
export const readYArray = _decoder => new YArray()

View File

@ -1,281 +0,0 @@
/**
* @module YMap
*/
import {
YEvent,
AbstractType,
typeMapDelete,
typeMapSet,
typeMapGet,
typeMapHas,
createMapIterator,
YMapRefID,
callTypeObservers,
transact,
warnPrematureAccess,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Transaction, Item // eslint-disable-line
} from '../internals.js'
import * as iterator from 'lib0/iterator'
/**
* @template T
* @extends YEvent<YMap<T>>
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
/**
* @param {YMap<T>} ymap The YArray that changed.
* @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed.
*/
constructor (ymap, transaction, subs) {
super(ymap, transaction)
this.keysChanged = subs
}
}
/**
* @template MapType
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<MapType>>
* @implements {Iterable<[string, MapType]>}
*/
export class YMap extends AbstractType {
/**
*
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
*/
constructor (entries) {
super()
/**
* @type {Map<string,any>?}
* @private
*/
this._prelimContent = null
if (entries === undefined) {
this._prelimContent = new Map()
} else {
this._prelimContent = new Map(entries)
}
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*/
_integrate (y, item) {
super._integrate(y, item)
;/** @type {Map<string, any>} */ (this._prelimContent).forEach((value, key) => {
this.set(key, value)
})
this._prelimContent = null
}
/**
* @return {YMap<MapType>}
*/
_copy () {
return new YMap()
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YMap<MapType>}
*/
clone () {
/**
* @type {YMap<MapType>}
*/
const map = new YMap()
this.forEach((value, key) => {
map.set(key, value instanceof AbstractType ? /** @type {typeof value} */ (value.clone()) : value)
})
return map
}
/**
* Creates YMapEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YMapEvent(this, transaction, parentSubs))
}
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object<string,any>}
*/
toJSON () {
this.doc ?? warnPrematureAccess()
/**
* @type {Object<string,MapType>}
*/
const map = {}
this._map.forEach((item, key) => {
if (!item.deleted) {
const v = item.content.getContent()[item.length - 1]
map[key] = v instanceof AbstractType ? v.toJSON() : v
}
})
return map
}
/**
* Returns the size of the YMap (count of key/value pairs)
*
* @return {number}
*/
get size () {
return [...createMapIterator(this)].length
}
/**
* Returns the keys for each element in the YMap Type.
*
* @return {IterableIterator<string>}
*/
keys () {
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[0])
}
/**
* Returns the values for each element in the YMap Type.
*
* @return {IterableIterator<MapType>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
}
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<[string, MapType]>}
*/
entries () {
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]]))
}
/**
* Executes a provided function on once on every key-value pair.
*
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
this.doc ?? warnPrematureAccess()
this._map.forEach((item, key) => {
if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this)
}
})
}
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<[string, MapType]>}
*/
[Symbol.iterator] () {
return this.entries()
}
/**
* Remove a specified element from this YMap.
*
* @param {string} key The key of the element to remove.
*/
delete (key) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapDelete(transaction, this, key)
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).delete(key)
}
}
/**
* Adds or updates an element with a specified key and value.
* @template {MapType} VAL
*
* @param {string} key The key of the element to add to this YMap
* @param {VAL} value The value of the element to add
* @return {VAL}
*/
set (key, value) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, key, /** @type {any} */ (value))
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
}
return value
}
/**
* Returns a specified element from this YMap.
*
* @param {string} key
* @return {MapType|undefined}
*/
get (key) {
return /** @type {any} */ (typeMapGet(this, key))
}
/**
* Returns a boolean indicating whether the specified key exists or not.
*
* @param {string} key The key to test.
* @return {boolean}
*/
has (key) {
return typeMapHas(this, key)
}
/**
* Removes all elements from this YMap.
*/
clear () {
if (this.doc !== null) {
transact(this.doc, transaction => {
this.forEach(function (_value, key, map) {
typeMapDelete(transaction, map, key)
})
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).clear()
}
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YMapRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
*
* @private
* @function
*/
export const readYMap = _decoder => new YMap()

File diff suppressed because it is too large Load Diff

View File

@ -1,262 +0,0 @@
import * as object from 'lib0/object'
import {
YXmlFragment,
transact,
typeMapDelete,
typeMapHas,
typeMapSet,
typeMapGet,
typeMapGetAll,
typeMapGetAllSnapshot,
typeListForEach,
YXmlElementRefID,
Snapshot, YXmlText, ContentType, AbstractType, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, Item // eslint-disable-line
} from '../internals.js'
/**
* @typedef {Object|number|null|Array<any>|string|Uint8Array|AbstractType<any>} ValueTypes
*/
/**
* An YXmlElement imitates the behavior of a
* https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element
*
* * An YXmlElement has attributes (key value pairs)
* * An YXmlElement has childElements that must inherit from YXmlElement
*
* @template {{ [key: string]: ValueTypes }} [KV={ [key: string]: string }]
*/
export class YXmlElement extends YXmlFragment {
constructor (nodeName = 'UNDEFINED') {
super()
this.nodeName = nodeName
/**
* @type {Map<string, any>|null}
*/
this._prelimAttrs = new Map()
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get nextSibling () {
const n = this._item ? this._item.next : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get prevSibling () {
const n = this._item ? this._item.prev : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*/
_integrate (y, item) {
super._integrate(y, item)
;(/** @type {Map<string, any>} */ (this._prelimAttrs)).forEach((value, key) => {
this.setAttribute(key, value)
})
this._prelimAttrs = null
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*
* @return {YXmlElement}
*/
_copy () {
return new YXmlElement(this.nodeName)
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YXmlElement<KV>}
*/
clone () {
/**
* @type {YXmlElement<KV>}
*/
const el = new YXmlElement(this.nodeName)
const attrs = this.getAttributes()
object.forEach(attrs, (value, key) => {
if (typeof value === 'string') {
el.setAttribute(key, value)
}
})
// @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
}
/**
* Returns the XML serialization of this YXmlElement.
* The attributes are ordered by attribute-name, so you can easily use this
* method to compare YXmlElements
*
* @return {string} The string representation of this type.
*
* @public
*/
toString () {
const attrs = this.getAttributes()
const stringBuilder = []
const keys = []
for (const key in attrs) {
keys.push(key)
}
keys.sort()
const keysLen = keys.length
for (let i = 0; i < keysLen; i++) {
const key = keys[i]
stringBuilder.push(key + '="' + attrs[key] + '"')
}
const nodeName = this.nodeName.toLocaleLowerCase()
const attrsString = stringBuilder.length > 0 ? ' ' + stringBuilder.join(' ') : ''
return `<${nodeName}${attrsString}>${super.toString()}</${nodeName}>`
}
/**
* Removes an attribute from this YXmlElement.
*
* @param {string} attributeName The attribute name that is to be removed.
*
* @public
*/
removeAttribute (attributeName) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapDelete(transaction, this, attributeName)
})
} else {
/** @type {Map<string,any>} */ (this._prelimAttrs).delete(attributeName)
}
}
/**
* Sets or updates an attribute.
*
* @template {keyof KV & string} KEY
*
* @param {KEY} attributeName The attribute name that is to be set.
* @param {KV[KEY]} attributeValue The attribute value that is to be set.
*
* @public
*/
setAttribute (attributeName, attributeValue) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, attributeName, attributeValue)
})
} else {
/** @type {Map<string, any>} */ (this._prelimAttrs).set(attributeName, attributeValue)
}
}
/**
* Returns an attribute value that belongs to the attribute name.
*
* @template {keyof KV & string} KEY
*
* @param {KEY} attributeName The attribute name that identifies the
* queried value.
* @return {KV[KEY]|undefined} The queried attribute value.
*
* @public
*/
getAttribute (attributeName) {
return /** @type {any} */ (typeMapGet(this, attributeName))
}
/**
* Returns whether an attribute exists
*
* @param {string} attributeName The attribute name to check for existence.
* @return {boolean} whether the attribute exists.
*
* @public
*/
hasAttribute (attributeName) {
return /** @type {any} */ (typeMapHas(this, attributeName))
}
/**
* Returns all attribute name/value pairs in a JSON Object.
*
* @param {Snapshot} [snapshot]
* @return {{ [Key in Extract<keyof KV,string>]?: KV[Key]}} A JSON Object that describes the attributes.
*
* @public
*/
getAttributes (snapshot) {
return /** @type {any} */ (snapshot ? typeMapGetAllSnapshot(this, snapshot) : typeMapGetAll(this))
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const dom = _document.createElement(this.nodeName)
const attrs = this.getAttributes()
for (const key in attrs) {
const value = attrs[key]
if (typeof value === 'string') {
dom.setAttribute(key, value)
}
}
typeListForEach(this, yxml => {
dom.appendChild(yxml.toDOM(_document, hooks, binding))
})
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlElementRefID)
encoder.writeKey(this.nodeName)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlElement}
*
* @function
*/
export const readYXmlElement = decoder => new YXmlElement(decoder.readKey())

View File

@ -1,39 +0,0 @@
import {
YEvent,
YXmlText, YXmlElement, YXmlFragment, Transaction // eslint-disable-line
} from '../internals.js'
/**
* @extends YEvent<YXmlElement|YXmlText|YXmlFragment>
* An Event that describes changes on a YXml Element or Yxml Fragment
*/
export class YXmlEvent extends YEvent {
/**
* @param {YXmlElement|YXmlText|YXmlFragment} target The target on which the event is created.
* @param {Set<string|null>} subs The set of changed attributes. `null` is included if the
* child list changed.
* @param {Transaction} transaction The transaction instance with which the
* change was created.
*/
constructor (target, subs, transaction) {
super(target, transaction)
/**
* Whether the children changed.
* @type {Boolean}
* @private
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string>}
*/
this.attributesChanged = new Set()
subs.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.attributesChanged.add(sub)
}
})
}
}

View File

@ -1,449 +0,0 @@
/**
* @module YXml
*/
import {
YXmlEvent,
YXmlElement,
AbstractType,
typeListMap,
typeListForEach,
typeListInsertGenerics,
typeListInsertGenericsAfter,
typeListDelete,
typeListToArray,
YXmlFragmentRefID,
callTypeObservers,
transact,
typeListGet,
typeListSlice,
warnPrematureAccess,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, Doc, ContentType, Transaction, Item, YXmlText, YXmlHook // eslint-disable-line
} from '../internals.js'
import * as error from 'lib0/error'
import * as array from 'lib0/array'
/**
* Define the elements to which a set of CSS queries apply.
* {@link https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors|CSS_Selectors}
*
* @example
* query = '.classSelector'
* query = 'nodeSelector'
* query = '#idSelector'
*
* @typedef {string} CSS_Selector
*/
/**
* Dom filter function.
*
* @callback domFilter
* @param {string} nodeName The nodeName of the element
* @param {Map} attributes The map of attributes.
* @return {boolean} Whether to include the Dom node in the YXmlElement.
*/
/**
* Represents a subset of the nodes of a YXmlElement / YXmlFragment and a
* position within them.
*
* Can be created with {@link YXmlFragment#createTreeWalker}
*
* @public
* @implements {Iterable<YXmlElement|YXmlText|YXmlElement|YXmlHook>}
*/
export class YXmlTreeWalker {
/**
* @param {YXmlFragment | YXmlElement} root
* @param {function(AbstractType<any>):boolean} [f]
*/
constructor (root, f = () => true) {
this._filter = f
this._root = root
/**
* @type {Item}
*/
this._currentNode = /** @type {Item} */ (root._start)
this._firstCall = true
root.doc ?? warnPrematureAccess()
}
[Symbol.iterator] () {
return this
}
/**
* Get the next node.
*
* @return {IteratorResult<YXmlElement|YXmlText|YXmlHook>} The next node.
*
* @public
*/
next () {
/**
* @type {Item|null}
*/
let n = this._currentNode
let type = n && n.content && /** @type {any} */ (n.content).type
if (n !== null && (!this._firstCall || n.deleted || !this._filter(type))) { // if first call, we check if we can use the first item
do {
type = /** @type {any} */ (n.content).type
if (!n.deleted && (type.constructor === YXmlElement || type.constructor === YXmlFragment) && type._start !== null) {
// walk down in the tree
n = type._start
} else {
// walk right or up in the tree
while (n !== null) {
/**
* @type {Item | null}
*/
const nxt = n.next
if (nxt !== null) {
n = nxt
break
} else if (n.parent === this._root) {
n = null
} else {
n = /** @type {AbstractType<any>} */ (n.parent)._item
}
}
}
} while (n !== null && (n.deleted || !this._filter(/** @type {ContentType} */ (n.content).type)))
}
this._firstCall = false
if (n === null) {
// @ts-ignore
return { value: undefined, done: true }
}
this._currentNode = n
return { value: /** @type {any} */ (n.content).type, done: false }
}
}
/**
* Represents a list of {@link YXmlElement}.and {@link YXmlText} types.
* A YxmlFragment is similar to a {@link YXmlElement}, but it does not have a
* nodeName and it does not have attributes. Though it can be bound to a DOM
* element - in this case the attributes and the nodeName are not shared.
*
* @public
* @extends AbstractType<YXmlEvent>
*/
export class YXmlFragment extends AbstractType {
constructor () {
super()
/**
* @type {Array<any>|null}
*/
this._prelimContent = []
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get firstChild () {
const first = this._first
return first ? first.content.getContent()[0] : null
}
/**
* Integrate this type into the Yjs instance.
*
* * Save this struct in the os
* * This type is sent to other client
* * Observer functions are fired
*
* @param {Doc} y The Yjs instance
* @param {Item} item
*/
_integrate (y, item) {
super._integrate(y, item)
this.insert(0, /** @type {Array<any>} */ (this._prelimContent))
this._prelimContent = null
}
_copy () {
return new YXmlFragment()
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YXmlFragment}
*/
clone () {
const el = new YXmlFragment()
// @ts-ignore
el.insert(0, this.toArray().map(item => item instanceof AbstractType ? item.clone() : item))
return el
}
get length () {
this.doc ?? warnPrematureAccess()
return this._prelimContent === null ? this._length : this._prelimContent.length
}
/**
* Create a subtree of childNodes.
*
* @example
* const walker = elem.createTreeWalker(dom => dom.nodeName === 'div')
* for (let node in walker) {
* // `node` is a div node
* nop(node)
* }
*
* @param {function(AbstractType<any>):boolean} filter Function that is called on each child element and
* returns a Boolean indicating whether the child
* is to be included in the subtree.
* @return {YXmlTreeWalker} A subtree and a position within it.
*
* @public
*/
createTreeWalker (filter) {
return new YXmlTreeWalker(this, filter)
}
/**
* Returns the first YXmlElement that matches the query.
* Similar to DOM's {@link querySelector}.
*
* Query support:
* - tagname
* TODO:
* - id
* - attribute
*
* @param {CSS_Selector} query The query on the children.
* @return {YXmlElement|YXmlText|YXmlHook|null} The first element that matches the query or null.
*
* @public
*/
querySelector (query) {
query = query.toUpperCase()
// @ts-ignore
const iterator = new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query)
const next = iterator.next()
if (next.done) {
return null
} else {
return next.value
}
}
/**
* Returns all YXmlElements that match the query.
* Similar to Dom's {@link querySelectorAll}.
*
* @todo Does not yet support all queries. Currently only query by tagName.
*
* @param {CSS_Selector} query The query on the children
* @return {Array<YXmlElement|YXmlText|YXmlHook|null>} The elements that match this query.
*
* @public
*/
querySelectorAll (query) {
query = query.toUpperCase()
// @ts-ignore
return array.from(new YXmlTreeWalker(this, element => element.nodeName && element.nodeName.toUpperCase() === query))
}
/**
* Creates YXmlEvent and calls observers.
*
* @param {Transaction} transaction
* @param {Set<null|string>} parentSubs Keys changed on this type. `null` if list was modified.
*/
_callObserver (transaction, parentSubs) {
callTypeObservers(this, transaction, new YXmlEvent(this, parentSubs, transaction))
}
/**
* Get the string representation of all the children of this YXmlFragment.
*
* @return {string} The string representation of all children.
*/
toString () {
return typeListMap(this, xml => xml.toString()).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks={}] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Node} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const fragment = _document.createDocumentFragment()
if (binding !== undefined) {
binding._createAssociation(fragment, this)
}
typeListForEach(this, xmlType => {
fragment.insertBefore(xmlType.toDOM(_document, hooks, binding), null)
})
return fragment
}
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {number} index The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insert (index, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListInsertGenerics(transaction, this, index, content)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, 0, ...content)
}
}
/**
* Inserts new content at an index.
*
* @example
* // Insert character 'a' at position 0
* xml.insert(0, [new Y.XmlText('text')])
*
* @param {null|Item|YXmlElement|YXmlText} ref The index to insert content at
* @param {Array<YXmlElement|YXmlText>} content The array of content
*/
insertAfter (ref, content) {
if (this.doc !== null) {
transact(this.doc, transaction => {
const refItem = (ref && ref instanceof AbstractType) ? ref._item : ref
typeListInsertGenericsAfter(transaction, this, refItem, content)
})
} else {
const pc = /** @type {Array<any>} */ (this._prelimContent)
const index = ref === null ? 0 : pc.findIndex(el => el === ref) + 1
if (index === 0 && ref !== null) {
throw error.create('Reference item not found')
}
pc.splice(index, 0, ...content)
}
}
/**
* Deletes elements starting from an index.
*
* @param {number} index Index at which to start deleting elements
* @param {number} [length=1] The number of elements to remove. Defaults to 1.
*/
delete (index, length = 1) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeListDelete(transaction, this, index, length)
})
} else {
// @ts-ignore _prelimContent is defined because this is not yet integrated
this._prelimContent.splice(index, length)
}
}
/**
* Transforms this YArray to a JavaScript Array.
*
* @return {Array<YXmlElement|YXmlText|YXmlHook>}
*/
toArray () {
return typeListToArray(this)
}
/**
* Appends content to this YArray.
*
* @param {Array<YXmlElement|YXmlText>} content Array of content to append.
*/
push (content) {
this.insert(this.length, content)
}
/**
* Prepends content to this YArray.
*
* @param {Array<YXmlElement|YXmlText>} content Array of content to prepend.
*/
unshift (content) {
this.insert(0, content)
}
/**
* Returns the i-th element from a YArray.
*
* @param {number} index The index of the element to return from the YArray
* @return {YXmlElement|YXmlText}
*/
get (index) {
return typeListGet(this, index)
}
/**
* Returns a portion of this YXmlFragment into a JavaScript Array selected
* from start to end (end not included).
*
* @param {number} [start]
* @param {number} [end]
* @return {Array<YXmlElement|YXmlText>}
*/
slice (start = 0, end = this.length) {
return typeListSlice(this, start, end)
}
/**
* Executes a provided function on once on every child element.
*
* @param {function(YXmlElement|YXmlText,number, typeof self):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
typeListForEach(this, f)
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlFragmentRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} _decoder
* @return {YXmlFragment}
*
* @private
* @function
*/
export const readYXmlFragment = _decoder => new YXmlFragment()

View File

@ -1,98 +0,0 @@
import {
YMap,
YXmlHookRefID,
UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2 // eslint-disable-line
} from '../internals.js'
/**
* You can manage binding to a custom type with YXmlHook.
*
* @extends {YMap<any>}
*/
export class YXmlHook extends YMap {
/**
* @param {string} hookName nodeName of the Dom Node.
*/
constructor (hookName) {
super()
/**
* @type {string}
*/
this.hookName = hookName
}
/**
* Creates an Item with the same effect as this Item (without position effect)
*/
_copy () {
return new YXmlHook(this.hookName)
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YXmlHook}
*/
clone () {
const el = new YXmlHook(this.hookName)
this.forEach((value, key) => {
el.set(key, value)
})
return el
}
/**
* Creates a Dom Element that mirrors this YXmlElement.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object.<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type
* @return {Element} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks = {}, binding) {
const hook = hooks[this.hookName]
let dom
if (hook !== undefined) {
dom = hook.createDom(this)
} else {
dom = document.createElement(this.hookName)
}
dom.setAttribute('data-yjs-hook', this.hookName)
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
/**
* Transform the properties of this type to binary and write it to an
* BinaryEncoder.
*
* This is called when this Item is sent to a remote peer.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to.
*/
_write (encoder) {
encoder.writeTypeRef(YXmlHookRefID)
encoder.writeKey(this.hookName)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlHook}
*
* @private
* @function
*/
export const readYXmlHook = decoder =>
new YXmlHook(decoder.readKey())

View File

@ -1,124 +0,0 @@
import {
YText,
YXmlTextRefID,
ContentType, YXmlElement, UpdateDecoderV1, UpdateDecoderV2, UpdateEncoderV1, UpdateEncoderV2, // eslint-disable-line
} from '../internals.js'
/**
* Represents text in a Dom Element. In the future this type will also handle
* simple formatting information like bold and italic.
*/
export class YXmlText extends YText {
/**
* @type {YXmlElement|YXmlText|null}
*/
get nextSibling () {
const n = this._item ? this._item.next : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
/**
* @type {YXmlElement|YXmlText|null}
*/
get prevSibling () {
const n = this._item ? this._item.prev : null
return n ? /** @type {YXmlElement|YXmlText} */ (/** @type {ContentType} */ (n.content).type) : null
}
_copy () {
return new YXmlText()
}
/**
* Makes a copy of this data type that can be included somewhere else.
*
* Note that the content is only readable _after_ it has been included somewhere in the Ydoc.
*
* @return {YXmlText}
*/
clone () {
const text = new YXmlText()
text.applyDelta(this.toDelta())
return text
}
/**
* Creates a Dom Element that mirrors this YXmlText.
*
* @param {Document} [_document=document] The document object (you must define
* this when calling this method in
* nodejs)
* @param {Object<string, any>} [hooks] Optional property to customize how hooks
* are presented in the DOM
* @param {any} [binding] You should not set this property. This is
* used if DomBinding wants to create a
* association to the created DOM type.
* @return {Text} The {@link https://developer.mozilla.org/en-US/docs/Web/API/Element|Dom Element}
*
* @public
*/
toDOM (_document = document, hooks, binding) {
const dom = _document.createTextNode(this.toString())
if (binding !== undefined) {
binding._createAssociation(dom, this)
}
return dom
}
toString () {
// @ts-ignore
return this.toDelta().map(delta => {
const nestedNodes = []
for (const nodeName in delta.attributes) {
const attrs = []
for (const key in delta.attributes[nodeName]) {
attrs.push({ key, value: delta.attributes[nodeName][key] })
}
// sort attributes to get a unique order
attrs.sort((a, b) => a.key < b.key ? -1 : 1)
nestedNodes.push({ nodeName, attrs })
}
// sort node order to get a unique order
nestedNodes.sort((a, b) => a.nodeName < b.nodeName ? -1 : 1)
// now convert to dom string
let str = ''
for (let i = 0; i < nestedNodes.length; i++) {
const node = nestedNodes[i]
str += `<${node.nodeName}`
for (let j = 0; j < node.attrs.length; j++) {
const attr = node.attrs[j]
str += ` ${attr.key}="${attr.value}"`
}
str += '>'
}
str += delta.insert
for (let i = nestedNodes.length - 1; i >= 0; i--) {
str += `</${nestedNodes[i].nodeName}>`
}
return str
}).join('')
}
/**
* @return {string}
*/
toJSON () {
return this.toString()
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
_write (encoder) {
encoder.writeTypeRef(YXmlTextRefID)
}
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @return {YXmlText}
*
* @private
* @function
*/
export const readYXmlText = decoder => new YXmlText()

View File

@ -1,25 +0,0 @@
import { ObservableV2 } from 'lib0/observable'
import {
Doc // eslint-disable-line
} from '../internals.js'
/**
* This is an abstract interface that all Connectors should implement to keep them interchangeable.
*
* @note This interface is experimental and it is not advised to actually inherit this class.
* It just serves as typing information.
*
* @extends {ObservableV2<any>}
*/
export class AbstractConnector extends ObservableV2 {
/**
* @param {Doc} ydoc
* @param {any} awareness
*/
constructor (ydoc, awareness) {
super()
this.doc = ydoc
this.awareness = awareness
}
}

View File

@ -1,349 +0,0 @@
import {
findIndexSS,
getState,
splitItem,
iterateStructs,
UpdateEncoderV2,
DSDecoderV1, DSEncoderV1, DSDecoderV2, DSEncoderV2, Item, GC, StructStore, Transaction, ID // eslint-disable-line
} from '../internals.js'
import * as array from 'lib0/array'
import * as math from 'lib0/math'
import * as map from 'lib0/map'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
export class DeleteItem {
/**
* @param {number} clock
* @param {number} len
*/
constructor (clock, len) {
/**
* @type {number}
*/
this.clock = clock
/**
* @type {number}
*/
this.len = len
}
}
/**
* We no longer maintain a DeleteStore. DeleteSet is a temporary object that is created when needed.
* - When created in a transaction, it must only be accessed after sorting, and merging
* - This DeleteSet is send to other clients
* - We do not create a DeleteSet when we send a sync message. The DeleteSet message is created directly from StructStore
* - We read a DeleteSet as part of a sync/update message. In this case the DeleteSet is already sorted and merged.
*/
export class DeleteSet {
constructor () {
/**
* @type {Map<number,Array<DeleteItem>>}
*/
this.clients = new Map()
}
}
/**
* Iterate over all structs that the DeleteSet gc's.
*
* @param {Transaction} transaction
* @param {DeleteSet} ds
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateDeletedStructs = (transaction, ds, f) =>
ds.clients.forEach((deletes, clientid) => {
const structs = /** @type {Array<GC|Item>} */ (transaction.doc.store.clients.get(clientid))
for (let i = 0; i < deletes.length; i++) {
const del = deletes[i]
iterateStructs(transaction, structs, del.clock, del.len, f)
}
})
/**
* @param {Array<DeleteItem>} dis
* @param {number} clock
* @return {number|null}
*
* @private
* @function
*/
export const findIndexDS = (dis, clock) => {
let left = 0
let right = dis.length - 1
while (left <= right) {
const midindex = math.floor((left + right) / 2)
const mid = dis[midindex]
const midclock = mid.clock
if (midclock <= clock) {
if (clock < midclock + mid.len) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
}
return null
}
/**
* @param {DeleteSet} ds
* @param {ID} id
* @return {boolean}
*
* @private
* @function
*/
export const isDeleted = (ds, id) => {
const dis = ds.clients.get(id.client)
return dis !== undefined && findIndexDS(dis, id.clock) !== null
}
/**
* @param {DeleteSet} ds
*
* @private
* @function
*/
export const sortAndMergeDeleteSet = ds => {
ds.clients.forEach(dels => {
dels.sort((a, b) => a.clock - b.clock)
// merge items without filtering or splicing the array
// i is the current pointer
// j refers to the current insert position for the pointed item
// try to merge dels[i] into dels[j-1] or set dels[j]=dels[i]
let i, j
for (i = 1, j = 1; i < dels.length; i++) {
const left = dels[j - 1]
const right = dels[i]
if (left.clock + left.len >= right.clock) {
left.len = math.max(left.len, right.clock + right.len - left.clock)
} else {
if (j < i) {
dels[j] = right
}
j++
}
}
dels.length = j
})
}
/**
* @param {Array<DeleteSet>} dss
* @return {DeleteSet} A fresh DeleteSet
*/
export const mergeDeleteSets = dss => {
const merged = new DeleteSet()
for (let dssI = 0; dssI < dss.length; dssI++) {
dss[dssI].clients.forEach((delsLeft, client) => {
if (!merged.clients.has(client)) {
// Write all missing keys from current ds and all following.
// If merged already contains `client` current ds has already been added.
/**
* @type {Array<DeleteItem>}
*/
const dels = delsLeft.slice()
for (let i = dssI + 1; i < dss.length; i++) {
array.appendTo(dels, dss[i].clients.get(client) || [])
}
merged.clients.set(client, dels)
}
})
}
sortAndMergeDeleteSet(merged)
return merged
}
/**
* @param {DeleteSet} ds
* @param {number} client
* @param {number} clock
* @param {number} length
*
* @private
* @function
*/
export const addToDeleteSet = (ds, client, clock, length) => {
map.setIfUndefined(ds.clients, client, () => /** @type {Array<DeleteItem>} */ ([])).push(new DeleteItem(clock, length))
}
export const createDeleteSet = () => new DeleteSet()
/**
* @param {StructStore} ss
* @return {DeleteSet} Merged and sorted DeleteSet
*
* @private
* @function
*/
export const createDeleteSetFromStructStore = ss => {
const ds = createDeleteSet()
ss.clients.forEach((structs, client) => {
/**
* @type {Array<DeleteItem>}
*/
const dsitems = []
for (let i = 0; i < structs.length; i++) {
const struct = structs[i]
if (struct.deleted) {
const clock = struct.id.clock
let len = struct.length
if (i + 1 < structs.length) {
for (let next = structs[i + 1]; i + 1 < structs.length && next.deleted; next = structs[++i + 1]) {
len += next.length
}
}
dsitems.push(new DeleteItem(clock, len))
}
}
if (dsitems.length > 0) {
ds.clients.set(client, dsitems)
}
})
return ds
}
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {DeleteSet} ds
*
* @private
* @function
*/
export const writeDeleteSet = (encoder, ds) => {
encoding.writeVarUint(encoder.restEncoder, ds.clients.size)
// Ensure that the delete set is written in a deterministic order
array.from(ds.clients.entries())
.sort((a, b) => b[0] - a[0])
.forEach(([client, dsitems]) => {
encoder.resetDsCurVal()
encoding.writeVarUint(encoder.restEncoder, client)
const len = dsitems.length
encoding.writeVarUint(encoder.restEncoder, len)
for (let i = 0; i < len; i++) {
const item = dsitems[i]
encoder.writeDsClock(item.clock)
encoder.writeDsLen(item.len)
}
})
}
/**
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @return {DeleteSet}
*
* @private
* @function
*/
export const readDeleteSet = decoder => {
const ds = new DeleteSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
if (numberOfDeletes > 0) {
const dsField = map.setIfUndefined(ds.clients, client, () => /** @type {Array<DeleteItem>} */ ([]))
for (let i = 0; i < numberOfDeletes; i++) {
dsField.push(new DeleteItem(decoder.readDsClock(), decoder.readDsLen()))
}
}
}
return ds
}
/**
* @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array()..
*/
/**
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @param {Transaction} transaction
* @param {StructStore} store
* @return {Uint8Array|null} Returns a v2 update containing all deletes that couldn't be applied yet; or null if all deletes were applied successfully.
*
* @private
* @function
*/
export const readAndApplyDeleteSet = (decoder, transaction, store) => {
const unappliedDS = new DeleteSet()
const numClients = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numClients; i++) {
decoder.resetDsCurVal()
const client = decoding.readVarUint(decoder.restDecoder)
const numberOfDeletes = decoding.readVarUint(decoder.restDecoder)
const structs = store.clients.get(client) || []
const state = getState(store, client)
for (let i = 0; i < numberOfDeletes; i++) {
const clock = decoder.readDsClock()
const clockEnd = clock + decoder.readDsLen()
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
}
}
} else {
addToDeleteSet(unappliedDS, client, clock, clockEnd - clock)
}
}
}
if (unappliedDS.clients.size > 0) {
const ds = new UpdateEncoderV2()
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
writeDeleteSet(ds, unappliedDS)
return ds.toUint8Array()
}
return null
}
/**
* @param {DeleteSet} ds1
* @param {DeleteSet} ds2
*/
export const equalDeleteSets = (ds1, ds2) => {
if (ds1.clients.size !== ds2.clients.size) return false
for (const [client, deleteItems1] of ds1.clients.entries()) {
const deleteItems2 = /** @type {Array<import('../internals.js').DeleteItem>} */ (ds2.clients.get(client))
if (deleteItems2 === undefined || deleteItems1.length !== deleteItems2.length) return false
for (let i = 0; i < deleteItems1.length; i++) {
const di1 = deleteItems1[i]
const di2 = deleteItems2[i]
if (di1.clock !== di2.clock || di1.len !== di2.len) {
return false
}
}
}
return true
}

View File

@ -1,347 +0,0 @@
/**
* @module Y
*/
import {
StructStore,
AbstractType,
YArray,
YText,
YMap,
YXmlElement,
YXmlFragment,
transact,
ContentDoc, Item, Transaction, YEvent // eslint-disable-line
} from '../internals.js'
import { ObservableV2 } from 'lib0/observable'
import * as random from 'lib0/random'
import * as map from 'lib0/map'
import * as array from 'lib0/array'
import * as promise from 'lib0/promise'
export const generateNewClientId = random.uint32
/**
* @typedef {Object} DocOpts
* @property {boolean} [DocOpts.gc=true] Disable garbage collection (default: gc=true)
* @property {function(Item):boolean} [DocOpts.gcFilter] Will be called before an Item is garbage collected. Return false to keep the Item.
* @property {string} [DocOpts.guid] Define a globally unique identifier for this document
* @property {string | null} [DocOpts.collectionid] Associate this document with a collection. This only plays a role if your provider has a concept of collection.
* @property {any} [DocOpts.meta] Any kind of meta information you want to associate with this document. If this is a subdocument, remote peers will store the meta information as well.
* @property {boolean} [DocOpts.autoLoad] If a subdocument, automatically load document. If this is a subdocument, remote peers will load the document as well automatically.
* @property {boolean} [DocOpts.shouldLoad] Whether the document should be synced by the provider now. This is toggled to true when you call ydoc.load()
*/
/**
* @typedef {Object} DocEvents
* @property {function(Doc):void} DocEvents.destroy
* @property {function(Doc):void} DocEvents.load
* @property {function(boolean, Doc):void} DocEvents.sync
* @property {function(Uint8Array, any, Doc, Transaction):void} DocEvents.update
* @property {function(Uint8Array, any, Doc, Transaction):void} DocEvents.updateV2
* @property {function(Doc):void} DocEvents.beforeAllTransactions
* @property {function(Transaction, Doc):void} DocEvents.beforeTransaction
* @property {function(Transaction, Doc):void} DocEvents.beforeObserverCalls
* @property {function(Transaction, Doc):void} DocEvents.afterTransaction
* @property {function(Transaction, Doc):void} DocEvents.afterTransactionCleanup
* @property {function(Doc, Array<Transaction>):void} DocEvents.afterAllTransactions
* @property {function({ loaded: Set<Doc>, added: Set<Doc>, removed: Set<Doc> }, Doc, Transaction):void} DocEvents.subdocs
*/
/**
* A Yjs instance handles the state of shared data.
* @extends ObservableV2<DocEvents>
*/
export class Doc extends ObservableV2 {
/**
* @param {DocOpts} opts configuration
*/
constructor ({ guid = random.uuidv4(), collectionid = null, gc = true, gcFilter = () => true, meta = null, autoLoad = false, shouldLoad = true } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
this.guid = guid
this.collectionid = collectionid
/**
* @type {Map<string, AbstractType<YEvent<any>>>}
*/
this.share = new Map()
this.store = new StructStore()
/**
* @type {Transaction | null}
*/
this._transaction = null
/**
* @type {Array<Transaction>}
*/
this._transactionCleanups = []
/**
* @type {Set<Doc>}
*/
this.subdocs = new Set()
/**
* If this document is a subdocument - a document integrated into another document - then _item is defined.
* @type {Item?}
*/
this._item = null
this.shouldLoad = shouldLoad
this.autoLoad = autoLoad
this.meta = meta
/**
* This is set to true when the persistence provider loaded the document from the database or when the `sync` event fires.
* Note that not all providers implement this feature. Provider authors are encouraged to fire the `load` event when the doc content is loaded from the database.
*
* @type {boolean}
*/
this.isLoaded = false
/**
* This is set to true when the connection provider has successfully synced with a backend.
* Note that when using peer-to-peer providers this event may not provide very useful.
* Also note that not all providers implement this feature. Provider authors are encouraged to fire
* the `sync` event when the doc has been synced (with `true` as a parameter) or if connection is
* lost (with false as a parameter).
*/
this.isSynced = false
this.isDestroyed = false
/**
* Promise that resolves once the document has been loaded from a persistence provider.
*/
this.whenLoaded = promise.create(resolve => {
this.on('load', () => {
this.isLoaded = true
resolve(this)
})
})
const provideSyncedPromise = () => promise.create(resolve => {
/**
* @param {boolean} isSynced
*/
const eventHandler = (isSynced) => {
if (isSynced === undefined || isSynced === true) {
this.off('sync', eventHandler)
resolve()
}
}
this.on('sync', eventHandler)
})
this.on('sync', isSynced => {
if (isSynced === false && this.isSynced) {
this.whenSynced = provideSyncedPromise()
}
this.isSynced = isSynced === undefined || isSynced === true
if (this.isSynced && !this.isLoaded) {
this.emit('load', [this])
}
})
/**
* Promise that resolves once the document has been synced with a backend.
* This promise is recreated when the connection is lost.
* Note the documentation about the `isSynced` property.
*/
this.whenSynced = provideSyncedPromise()
}
/**
* Notify the parent document that you request to load data into this subdocument (if it is a subdocument).
*
* `load()` might be used in the future to request any provider to load the most current data.
*
* It is safe to call `load()` multiple times.
*/
load () {
const item = this._item
if (item !== null && !this.shouldLoad) {
transact(/** @type {any} */ (item.parent).doc, transaction => {
transaction.subdocsLoaded.add(this)
}, null, true)
}
this.shouldLoad = true
}
getSubdocs () {
return this.subdocs
}
getSubdocGuids () {
return new Set(array.from(this.subdocs).map(doc => doc.guid))
}
/**
* Changes that happen inside of a transaction are bundled. This means that
* the observer fires _after_ the transaction is finished and that all changes
* that happened inside of the transaction are sent as one message to the
* other peers.
*
* @template T
* @param {function(Transaction):T} f The function that should be executed as a transaction
* @param {any} [origin] Origin of who started the transaction. Will be stored on transaction.origin
* @return T
*
* @public
*/
transact (f, origin = null) {
return transact(this, f, origin)
}
/**
* Define a shared data type.
*
* Multiple calls of `ydoc.get(name, TypeConstructor)` yield the same result
* and do not overwrite each other. I.e.
* `ydoc.get(name, Y.Array) === ydoc.get(name, Y.Array)`
*
* After this method is called, the type is also available on `ydoc.share.get(name)`.
*
* *Best Practices:*
* Define all types right after the Y.Doc instance is created and store them in a separate object.
* Also use the typed methods `getText(name)`, `getArray(name)`, ..
*
* @template {typeof AbstractType<any>} Type
* @example
* const ydoc = new Y.Doc(..)
* const appState = {
* document: ydoc.getText('document')
* comments: ydoc.getArray('comments')
* }
*
* @param {string} name
* @param {Type} TypeConstructor The constructor of the type definition. E.g. Y.Text, Y.Array, Y.Map, ...
* @return {InstanceType<Type>} The created type. Constructed with TypeConstructor
*
* @public
*/
get (name, TypeConstructor = /** @type {any} */ (AbstractType)) {
const type = map.setIfUndefined(this.share, name, () => {
// @ts-ignore
const t = new TypeConstructor()
t._integrate(this, null)
return t
})
const Constr = type.constructor
if (TypeConstructor !== AbstractType && Constr !== TypeConstructor) {
if (Constr === AbstractType) {
// @ts-ignore
const t = new TypeConstructor()
t._map = type._map
type._map.forEach(/** @param {Item?} n */ n => {
for (; n !== null; n = n.left) {
// @ts-ignore
n.parent = t
}
})
t._start = type._start
for (let n = t._start; n !== null; n = n.right) {
n.parent = t
}
t._length = type._length
this.share.set(name, t)
t._integrate(this, null)
return /** @type {InstanceType<Type>} */ (t)
} else {
throw new Error(`Type with the name ${name} has already been defined with a different constructor`)
}
}
return /** @type {InstanceType<Type>} */ (type)
}
/**
* @template T
* @param {string} [name]
* @return {YArray<T>}
*
* @public
*/
getArray (name = '') {
return /** @type {YArray<T>} */ (this.get(name, YArray))
}
/**
* @param {string} [name]
* @return {YText}
*
* @public
*/
getText (name = '') {
return this.get(name, YText)
}
/**
* @template T
* @param {string} [name]
* @return {YMap<T>}
*
* @public
*/
getMap (name = '') {
return /** @type {YMap<T>} */ (this.get(name, YMap))
}
/**
* @param {string} [name]
* @return {YXmlElement}
*
* @public
*/
getXmlElement (name = '') {
return /** @type {YXmlElement<{[key:string]:string}>} */ (this.get(name, YXmlElement))
}
/**
* @param {string} [name]
* @return {YXmlFragment}
*
* @public
*/
getXmlFragment (name = '') {
return this.get(name, YXmlFragment)
}
/**
* Converts the entire document into a js object, recursively traversing each yjs type
* Doesn't log types that have not been defined (using ydoc.getType(..)).
*
* @deprecated Do not use this method and rather call toJSON directly on the shared types.
*
* @return {Object<string, any>}
*/
toJSON () {
/**
* @type {Object<string, any>}
*/
const doc = {}
this.share.forEach((value, key) => {
doc[key] = value.toJSON()
})
return doc
}
/**
* Emit `destroy` event and unregister all event handlers.
*/
destroy () {
this.isDestroyed = true
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
const item = this._item
if (item !== null) {
this._item = null
const content = /** @type {ContentDoc} */ (item.content)
content.doc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
content.doc._item = item
transact(/** @type {any} */ (item).parent.doc, transaction => {
const doc = content.doc
if (!item.deleted) {
transaction.subdocsAdded.add(doc)
}
transaction.subdocsRemoved.add(this)
}, null, true)
}
// @ts-ignore
this.emit('destroyed', [true]) // DEPRECATED!
this.emit('destroy', [this])
super.destroy()
}
}

View File

@ -1,87 +0,0 @@
import * as f from 'lib0/function'
/**
* General event handler implementation.
*
* @template ARG0, ARG1
*
* @private
*/
export class EventHandler {
constructor () {
/**
* @type {Array<function(ARG0, ARG1):void>}
*/
this.l = []
}
}
/**
* @template ARG0,ARG1
* @returns {EventHandler<ARG0,ARG1>}
*
* @private
* @function
*/
export const createEventHandler = () => new EventHandler()
/**
* Adds an event listener that is called when
* {@link EventHandler#callEventListeners} is called.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {function(ARG0,ARG1):void} f The event handler.
*
* @private
* @function
*/
export const addEventHandlerListener = (eventHandler, f) =>
eventHandler.l.push(f)
/**
* Removes an event listener.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {function(ARG0,ARG1):void} f The event handler that was added with
* {@link EventHandler#addEventListener}
*
* @private
* @function
*/
export const removeEventHandlerListener = (eventHandler, f) => {
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.')
}
}
/**
* Removes all event listeners.
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
*
* @private
* @function
*/
export const removeAllEventHandlerListeners = eventHandler => {
eventHandler.l.length = 0
}
/**
* Call all event listeners that were added via
* {@link EventHandler#addEventListener}.
*
* @template ARG0,ARG1
* @param {EventHandler<ARG0,ARG1>} eventHandler
* @param {ARG0} arg0
* @param {ARG1} arg1
*
* @private
* @function
*/
export const callEventHandlerListeners = (eventHandler, arg0, arg1) =>
f.callAll(eventHandler.l, [arg0, arg1])

View File

@ -1,89 +0,0 @@
import { AbstractType } from '../internals.js' // eslint-disable-line
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as error from 'lib0/error'
export class ID {
/**
* @param {number} client client id
* @param {number} clock unique per client id, continuous number
*/
constructor (client, clock) {
/**
* Client id
* @type {number}
*/
this.client = client
/**
* unique per client id, continuous number
* @type {number}
*/
this.clock = clock
}
}
/**
* @param {ID | null} a
* @param {ID | null} b
* @return {boolean}
*
* @function
*/
export const compareIDs = (a, b) => a === b || (a !== null && b !== null && a.client === b.client && a.clock === b.clock)
/**
* @param {number} client
* @param {number} clock
*
* @private
* @function
*/
export const createID = (client, clock) => new ID(client, clock)
/**
* @param {encoding.Encoder} encoder
* @param {ID} id
*
* @private
* @function
*/
export const writeID = (encoder, id) => {
encoding.writeVarUint(encoder, id.client)
encoding.writeVarUint(encoder, id.clock)
}
/**
* Read ID.
* * If first varUint read is 0xFFFFFF a RootID is returned.
* * Otherwise an ID is returned
*
* @param {decoding.Decoder} decoder
* @return {ID}
*
* @private
* @function
*/
export const readID = decoder =>
createID(decoding.readVarUint(decoder), decoding.readVarUint(decoder))
/**
* The top types are mapped from y.share.get(keyname) => type.
* `type` does not store any information about the `keyname`.
* This function finds the correct `keyname` for `type` and throws otherwise.
*
* @param {AbstractType<any>} type
* @return {string}
*
* @private
* @function
*/
export const findRootTypeKey = type => {
// @ts-ignore _y must be defined, otherwise unexpected case
for (const [key, value] of type.doc.share.entries()) {
if (value === type) {
return key
}
}
throw error.unexpectedCase()
}

View File

@ -1,141 +0,0 @@
import {
YArray,
YMap,
readDeleteSet,
writeDeleteSet,
createDeleteSet,
DSEncoderV1, DSDecoderV1, ID, DeleteSet, YArrayEvent, Transaction, Doc // eslint-disable-line
} from '../internals.js'
import * as decoding from 'lib0/decoding'
import { mergeDeleteSets, isDeleted } from './DeleteSet.js'
export class PermanentUserData {
/**
* @param {Doc} doc
* @param {YMap<any>} [storeType]
*/
constructor (doc, storeType = doc.getMap('users')) {
/**
* @type {Map<string,DeleteSet>}
*/
const dss = new Map()
this.yusers = storeType
this.doc = doc
/**
* Maps from clientid to userDescription
*
* @type {Map<number,string>}
*/
this.clients = new Map()
this.dss = dss
/**
* @param {YMap<any>} user
* @param {string} userDescription
*/
const initUser = (user, userDescription) => {
/**
* @type {YArray<Uint8Array>}
*/
const ds = user.get('ds')
const ids = user.get('ids')
const addClientId = /** @param {number} clientid */ clientid => this.clients.set(clientid, userDescription)
ds.observe(/** @param {YArrayEvent<any>} event */ event => {
event.changes.added.forEach(item => {
item.content.getContent().forEach(encodedDs => {
if (encodedDs instanceof Uint8Array) {
this.dss.set(userDescription, mergeDeleteSets([this.dss.get(userDescription) || createDeleteSet(), readDeleteSet(new DSDecoderV1(decoding.createDecoder(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))
)
ids.forEach(addClientId)
}
// observe users
storeType.observe(event => {
event.keysChanged.forEach(userDescription =>
initUser(storeType.get(userDescription), userDescription)
)
})
// add initial data
storeType.forEach(initUser)
}
/**
* @param {Doc} doc
* @param {number} clientid
* @param {string} userDescription
* @param {Object} conf
* @param {function(Transaction, DeleteSet):boolean} [conf.filter]
*/
setUserMapping (doc, clientid, userDescription, { filter = () => true } = {}) {
const users = this.yusers
let user = users.get(userDescription)
if (!user) {
user = new YMap()
user.set('ids', new YArray())
user.set('ds', new YArray())
users.set(userDescription, user)
}
user.get('ids').push([clientid])
users.observe(_event => {
setTimeout(() => {
const userOverwrite = users.get(userDescription)
if (userOverwrite !== user) {
// user was overwritten, port all data over to the next user object
// @todo Experiment with Y.Sets here
user = userOverwrite
// @todo iterate over old type
this.clients.forEach((_userDescription, clientid) => {
if (userDescription === _userDescription) {
user.get('ids').push([clientid])
}
})
const encoder = new DSEncoderV1()
const ds = this.dss.get(userDescription)
if (ds) {
writeDeleteSet(encoder, ds)
user.get('ds').push([encoder.toUint8Array()])
}
}
}, 0)
})
doc.on('afterTransaction', /** @param {Transaction} transaction */ transaction => {
setTimeout(() => {
const yds = user.get('ds')
const ds = transaction.deleteSet
if (transaction.local && ds.clients.size > 0 && filter(transaction, ds)) {
const encoder = new DSEncoderV1()
writeDeleteSet(encoder, ds)
yds.push([encoder.toUint8Array()])
}
})
})
}
/**
* @param {number} clientid
* @return {any}
*/
getUserByClientId (clientid) {
return this.clients.get(clientid) || null
}
/**
* @param {ID} id
* @return {string | null}
*/
getUserByDeletedId (id) {
for (const [userDescription, ds] of this.dss.entries()) {
if (isDeleted(ds, id)) {
return userDescription
}
}
return null
}
}

View File

@ -1,353 +0,0 @@
import {
writeID,
readID,
compareIDs,
getState,
findRootTypeKey,
Item,
createID,
ContentType,
followRedone,
getItem,
StructStore, ID, Doc, AbstractType, // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as error from 'lib0/error'
/**
* A relative position is based on the Yjs model and is not affected by document changes.
* E.g. If you place a relative position before a certain character, it will always point to this character.
* If you place a relative position at the end of a type, it will always point to the end of the type.
*
* A numeric position is often unsuited for user selections, because it does not change when content is inserted
* before or after.
*
* ```Insert(0, 'x')('a|bc') = 'xa|bc'``` Where | is the relative position.
*
* One of the properties must be defined.
*
* @example
* // Current cursor position is at position 10
* const relativePosition = createRelativePositionFromIndex(yText, 10)
* // modify yText
* yText.insert(0, 'abc')
* yText.delete(3, 10)
* // Compute the cursor position
* const absolutePosition = createAbsolutePositionFromRelativePosition(y, relativePosition)
* absolutePosition.type === yText // => true
* console.log('cursor location is ' + absolutePosition.index) // => cursor location is 3
*
*/
export class RelativePosition {
/**
* @param {ID|null} type
* @param {string|null} tname
* @param {ID|null} item
* @param {number} assoc
*/
constructor (type, tname, item, assoc = 0) {
/**
* @type {ID|null}
*/
this.type = type
/**
* @type {string|null}
*/
this.tname = tname
/**
* @type {ID | null}
*/
this.item = item
/**
* A relative position is associated to a specific character. By default
* assoc >= 0, the relative position is associated to the character
* after the meant position.
* I.e. position 1 in 'ab' is associated to character 'b'.
*
* If assoc < 0, then the relative position is associated to the character
* before the meant position.
*
* @type {number}
*/
this.assoc = assoc
}
}
/**
* @param {RelativePosition} rpos
* @return {any}
*/
export const relativePositionToJSON = rpos => {
const json = {}
if (rpos.type) {
json.type = rpos.type
}
if (rpos.tname) {
json.tname = rpos.tname
}
if (rpos.item) {
json.item = rpos.item
}
if (rpos.assoc != null) {
json.assoc = rpos.assoc
}
return json
}
/**
* @param {any} json
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromJSON = json => new RelativePosition(json.type == null ? null : createID(json.type.client, json.type.clock), json.tname ?? null, json.item == null ? null : createID(json.item.client, json.item.clock), json.assoc == null ? 0 : json.assoc)
export class AbsolutePosition {
/**
* @param {AbstractType<any>} type
* @param {number} index
* @param {number} [assoc]
*/
constructor (type, index, assoc = 0) {
/**
* @type {AbstractType<any>}
*/
this.type = type
/**
* @type {number}
*/
this.index = index
this.assoc = assoc
}
}
/**
* @param {AbstractType<any>} type
* @param {number} index
* @param {number} [assoc]
*
* @function
*/
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
/**
* @param {AbstractType<any>} type
* @param {ID|null} item
* @param {number} [assoc]
*
* @function
*/
export const createRelativePosition = (type, item, assoc) => {
let typeid = null
let tname = null
if (type._item === null) {
tname = findRootTypeKey(type)
} else {
typeid = createID(type._item.id.client, type._item.id.clock)
}
return new RelativePosition(typeid, tname, item, assoc)
}
/**
* Create a relativePosition based on a absolute position.
*
* @param {AbstractType<any>} type The base type (e.g. YText or YArray).
* @param {number} index The absolute position.
* @param {number} [assoc]
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0) => {
let t = type._start
if (assoc < 0) {
// associated to the left character or the beginning of a type, increment index if possible.
if (index === 0) {
return createRelativePosition(type, null, assoc)
}
index--
}
while (t !== null) {
if (!t.deleted && t.countable) {
if (t.length > index) {
// case 1: found position somewhere in the linked list
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
}
index -= t.length
}
if (t.right === null && assoc < 0) {
// left-associated position, return last available id
return createRelativePosition(type, t.lastId, assoc)
}
t = t.right
}
return createRelativePosition(type, null, assoc)
}
/**
* @param {encoding.Encoder} encoder
* @param {RelativePosition} rpos
*
* @function
*/
export const writeRelativePosition = (encoder, rpos) => {
const { type, tname, item, assoc } = rpos
if (item !== null) {
encoding.writeVarUint(encoder, 0)
writeID(encoder, item)
} else if (tname !== null) {
// case 2: found position at the end of the list and type is stored in y.share
encoding.writeUint8(encoder, 1)
encoding.writeVarString(encoder, tname)
} else if (type !== null) {
// case 3: found position at the end of the list and type is attached to an item
encoding.writeUint8(encoder, 2)
writeID(encoder, type)
} else {
throw error.unexpectedCase()
}
encoding.writeVarInt(encoder, assoc)
return encoder
}
/**
* @param {RelativePosition} rpos
* @return {Uint8Array}
*/
export const encodeRelativePosition = rpos => {
const encoder = encoding.createEncoder()
writeRelativePosition(encoder, rpos)
return encoding.toUint8Array(encoder)
}
/**
* @param {decoding.Decoder} decoder
* @return {RelativePosition}
*
* @function
*/
export const readRelativePosition = decoder => {
let type = null
let tname = null
let itemID = null
switch (decoding.readVarUint(decoder)) {
case 0:
// case 1: found position somewhere in the linked list
itemID = readID(decoder)
break
case 1:
// case 2: found position at the end of the list and type is stored in y.share
tname = decoding.readVarString(decoder)
break
case 2: {
// case 3: found position at the end of the list and type is attached to an item
type = readID(decoder)
}
}
const assoc = decoding.hasContent(decoder) ? decoding.readVarInt(decoder) : 0
return new RelativePosition(type, tname, itemID, assoc)
}
/**
* @param {Uint8Array} uint8Array
* @return {RelativePosition}
*/
export const decodeRelativePosition = uint8Array => readRelativePosition(decoding.createDecoder(uint8Array))
/**
* @param {StructStore} store
* @param {ID} id
*/
const getItemWithOffset = (store, id) => {
const item = getItem(store, id)
const diff = id.clock - item.id.clock
return {
item, diff
}
}
/**
* Transform a relative position to an absolute position.
*
* If you want to share the relative position with other users, you should set
* `followUndoneDeletions` to false to get consistent results across all clients.
*
* When calculating the absolute position, we try to follow the "undone deletions". This yields
* better results for the user who performed undo. However, only the user who performed the undo
* will get the better results, the other users don't know which operations recreated a deleted
* range of content. There is more information in this ticket: https://github.com/yjs/yjs/issues/638
*
* @param {RelativePosition} rpos
* @param {Doc} doc
* @param {boolean} followUndoneDeletions - whether to follow undone deletions - see https://github.com/yjs/yjs/issues/638
* @return {AbsolutePosition|null}
*
* @function
*/
export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true) => {
const store = doc.store
const rightID = rpos.item
const typeID = rpos.type
const tname = rpos.tname
const assoc = rpos.assoc
let type = null
let index = 0
if (rightID !== null) {
if (getState(store, rightID.client) <= rightID.clock) {
return null
}
const res = followUndoneDeletions ? followRedone(store, rightID) : getItemWithOffset(store, rightID)
const right = res.item
if (!(right instanceof Item)) {
return null
}
type = /** @type {AbstractType<any>} */ (right.parent)
if (type._item === null || !type._item.deleted) {
index = (right.deleted || !right.countable) ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
let n = right.left
while (n !== null) {
if (!n.deleted && n.countable) {
index += n.length
}
n = n.left
}
}
} else {
if (tname !== null) {
type = doc.get(tname)
} else if (typeID !== null) {
if (getState(store, typeID.client) <= typeID.clock) {
// type does not exist yet
return null
}
const { item } = followUndoneDeletions ? followRedone(store, typeID) : { item: getItem(store, typeID) }
if (item instanceof Item && item.content instanceof ContentType) {
type = item.content.type
} else {
// struct is garbage collected
return null
}
} else {
throw error.unexpectedCase()
}
if (assoc >= 0) {
index = type._length
} else {
index = 0
}
}
return createAbsolutePosition(type, index, rpos.assoc)
}
/**
* @param {RelativePosition|null} a
* @param {RelativePosition|null} b
* @return {boolean}
*
* @function
*/
export const compareRelativePositions = (a, b) => a === b || (
a !== null && b !== null && a.tname === b.tname && compareIDs(a.item, b.item) && compareIDs(a.type, b.type) && a.assoc === b.assoc
)

View File

@ -1,236 +0,0 @@
import {
isDeleted,
createDeleteSetFromStructStore,
getStateVector,
getItemCleanStart,
iterateDeletedStructs,
writeDeleteSet,
writeStateVector,
readDeleteSet,
readStateVector,
createDeleteSet,
createID,
getState,
findIndexSS,
UpdateEncoderV2,
applyUpdateV2,
LazyStructReader,
equalDeleteSets,
UpdateDecoderV1, UpdateDecoderV2, DSEncoderV1, DSEncoderV2, DSDecoderV1, DSDecoderV2, Transaction, Doc, DeleteSet, Item, // eslint-disable-line
mergeDeleteSets
} from '../internals.js'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
export class Snapshot {
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sv state map
*/
constructor (ds, sv) {
/**
* @type {DeleteSet}
*/
this.ds = ds
/**
* State Map
* @type {Map<number,number>}
*/
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.entries()) {
if (sv2.get(key) !== value) {
return false
}
}
for (const [client, dsitems1] of ds1.entries()) {
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
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @return {Uint8Array}
*/
export const encodeSnapshotV2 = (snapshot, encoder = new DSEncoderV2()) => {
writeDeleteSet(encoder, snapshot.ds)
writeStateVector(encoder, snapshot.sv)
return encoder.toUint8Array()
}
/**
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new DSEncoderV1())
/**
* @param {Uint8Array} buf
* @param {DSDecoderV1 | DSDecoderV2} [decoder]
* @return {Snapshot}
*/
export const decodeSnapshotV2 = (buf, decoder = new DSDecoderV2(decoding.createDecoder(buf))) => {
return new Snapshot(readDeleteSet(decoder), readStateVector(decoder))
}
/**
* @param {Uint8Array} buf
* @return {Snapshot}
*/
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new DSDecoderV1(decoding.createDecoder(buf)))
/**
* @param {DeleteSet} ds
* @param {Map<number,number>} sm
* @return {Snapshot}
*/
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
export const emptySnapshot = createSnapshot(createDeleteSet(), new Map())
/**
* @param {Doc} doc
* @return {Snapshot}
*/
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
/**
* @param {Item} item
* @param {Snapshot|undefined} snapshot
*
* @protected
* @function
*/
export const isVisible = (item, snapshot) => snapshot === undefined
? !item.deleted
: snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !isDeleted(snapshot.ds, item.id)
/**
* @param {Transaction} transaction
* @param {Snapshot} snapshot
*/
export const splitSnapshotAffectedStructs = (transaction, snapshot) => {
const meta = map.setIfUndefined(transaction.meta, splitSnapshotAffectedStructs, set.create)
const store = transaction.doc.store
// check if we already split for this snapshot
if (!meta.has(snapshot)) {
snapshot.sv.forEach((clock, client) => {
if (clock < getState(store, client)) {
getItemCleanStart(transaction, createID(client, clock))
}
})
iterateDeletedStructs(transaction, snapshot.ds, _item => {})
meta.add(snapshot)
}
}
/**
* @example
* const ydoc = new Y.Doc({ gc: false })
* ydoc.getText().insert(0, 'world!')
* const snapshot = Y.snapshot(ydoc)
* ydoc.getText().insert(0, 'hello ')
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
* assert(restored.getText().toString() === 'world!')
*
* @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('Garbage-collection must be disabled in `originDoc`!')
}
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
}
/**
* @param {Snapshot} snapshot
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*/
export const snapshotContainsUpdateV2 = (snapshot, update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
if ((snapshot.sv.get(curr.id.client) || 0) < curr.id.clock + curr.length) {
return false
}
}
const mergedDS = mergeDeleteSets([snapshot.ds, readDeleteSet(updateDecoder)])
return equalDeleteSets(snapshot.ds, mergedDS)
}
/**
* @param {Snapshot} snapshot
* @param {Uint8Array} update
*/
export const snapshotContainsUpdate = (snapshot, update) => snapshotContainsUpdateV2(snapshot, update, UpdateDecoderV1)

View File

@ -1,261 +0,0 @@
import {
GC,
splitItem,
Transaction, ID, Item, DSDecoderV2 // eslint-disable-line
} from '../internals.js'
import * as math from 'lib0/math'
import * as error from 'lib0/error'
export class StructStore {
constructor () {
/**
* @type {Map<number,Array<GC|Item>>}
*/
this.clients = new Map()
/**
* @type {null | { missing: Map<number, number>, update: Uint8Array }}
*/
this.pendingStructs = null
/**
* @type {null | Uint8Array}
*/
this.pendingDs = null
}
}
/**
* Return the states as a Map<client,clock>.
* Note that clock refers to the next expected clock id.
*
* @param {StructStore} store
* @return {Map<number,number>}
*
* @public
* @function
*/
export const getStateVector = store => {
const sm = new Map()
store.clients.forEach((structs, client) => {
const struct = structs[structs.length - 1]
sm.set(client, struct.id.clock + struct.length)
})
return sm
}
/**
* @param {StructStore} store
* @param {number} client
* @return {number}
*
* @public
* @function
*/
export const getState = (store, client) => {
const structs = store.clients.get(client)
if (structs === undefined) {
return 0
}
const lastStruct = structs[structs.length - 1]
return lastStruct.id.clock + lastStruct.length
}
/**
* @param {StructStore} store
*
* @private
* @function
*/
export const integrityCheck = store => {
store.clients.forEach(structs => {
for (let i = 1; i < structs.length; i++) {
const l = structs[i - 1]
const r = structs[i]
if (l.id.clock + l.length !== r.id.clock) {
throw new Error('StructStore failed integrity check')
}
}
})
}
/**
* @param {StructStore} store
* @param {GC|Item} struct
*
* @private
* @function
*/
export const addStruct = (store, struct) => {
let structs = store.clients.get(struct.id.client)
if (structs === undefined) {
structs = []
store.clients.set(struct.id.client, structs)
} else {
const lastStruct = structs[structs.length - 1]
if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) {
throw error.unexpectedCase()
}
}
structs.push(struct)
}
/**
* Perform a binary search on a sorted array
* @param {Array<Item|GC>} structs
* @param {number} clock
* @return {number}
*
* @private
* @function
*/
export const findIndexSS = (structs, clock) => {
let left = 0
let right = structs.length - 1
let mid = structs[right]
let midclock = mid.id.clock
if (midclock === clock) {
return right
}
// @todo does it even make sense to pivot the search?
// If a good split misses, it might actually increase the time to find the correct item.
// Currently, the only advantage is that search with pivoting might find the item on the first try.
let midindex = math.floor((clock / (midclock + mid.length - 1)) * right) // pivoting the search
while (left <= right) {
mid = structs[midindex]
midclock = mid.id.clock
if (midclock <= clock) {
if (clock < midclock + mid.length) {
return midindex
}
left = midindex + 1
} else {
right = midindex - 1
}
midindex = math.floor((left + right) / 2)
}
// Always check state before looking for a struct in StructStore
// Therefore the case of not finding a struct is unexpected
throw error.unexpectedCase()
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {StructStore} store
* @param {ID} id
* @return {GC|Item}
*
* @private
* @function
*/
export const find = (store, id) => {
/**
* @type {Array<GC|Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
return structs[findIndexSS(structs, id.clock)]
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
* @private
* @function
*/
export const getItem = /** @type {function(StructStore,ID):Item} */ (find)
/**
* @param {Transaction} transaction
* @param {Array<Item|GC>} structs
* @param {number} clock
*/
export const findIndexCleanStart = (transaction, structs, clock) => {
const index = findIndexSS(structs, clock)
const struct = structs[index]
if (struct.id.clock < clock && struct instanceof Item) {
structs.splice(index + 1, 0, splitItem(transaction, struct, clock - struct.id.clock))
return index + 1
}
return index
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {Transaction} transaction
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanStart = (transaction, id) => {
const structs = /** @type {Array<Item>} */ (transaction.doc.store.clients.get(id.client))
return structs[findIndexCleanStart(transaction, structs, id.clock)]
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {ID} id
* @return {Item}
*
* @private
* @function
*/
export const getItemCleanEnd = (transaction, store, id) => {
/**
* @type {Array<Item>}
*/
// @ts-ignore
const structs = store.clients.get(id.client)
const index = findIndexSS(structs, id.clock)
const struct = structs[index]
if (id.clock !== struct.id.clock + struct.length - 1 && struct.constructor !== GC) {
structs.splice(index + 1, 0, splitItem(transaction, struct, id.clock - struct.id.clock + 1))
}
return struct
}
/**
* Replace `item` with `newitem` in store
* @param {StructStore} store
* @param {GC|Item} struct
* @param {GC|Item} newStruct
*
* @private
* @function
*/
export const replaceStruct = (store, struct, newStruct) => {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(struct.id.client))
structs[findIndexSS(structs, struct.id.clock)] = newStruct
}
/**
* Iterate over a range of structs
*
* @param {Transaction} transaction
* @param {Array<Item|GC>} structs
* @param {number} clockStart Inclusive start
* @param {number} len
* @param {function(GC|Item):void} f
*
* @function
*/
export const iterateStructs = (transaction, structs, clockStart, len, f) => {
if (len === 0) {
return
}
const clockEnd = clockStart + len
let index = findIndexCleanStart(transaction, structs, clockStart)
let struct
do {
struct = structs[index++]
if (clockEnd < struct.id.clock + struct.length) {
findIndexCleanStart(transaction, structs, clockEnd)
}
f(struct)
} while (index < structs.length && structs[index].id.clock < clockEnd)
}

View File

@ -1,444 +0,0 @@
import {
getState,
writeStructsFromTransaction,
writeDeleteSet,
DeleteSet,
sortAndMergeDeleteSet,
getStateVector,
findIndexSS,
callEventHandlerListeners,
Item,
generateNewClientId,
createID,
cleanupYTextAfterTransaction,
UpdateEncoderV1, UpdateEncoderV2, GC, StructStore, AbstractType, AbstractStruct, YEvent, Doc // eslint-disable-line
} from '../internals.js'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as set from 'lib0/set'
import * as logging from 'lib0/logging'
import { callAll } from 'lib0/function'
/**
* A transaction is created for every change on the Yjs model. It is possible
* to bundle changes on the Yjs model in a single transaction to
* minimize the number on messages sent and the number of observer calls.
* If possible the user of this library should bundle as many changes as
* possible. Here is an example to illustrate the advantages of bundling:
*
* @example
* const ydoc = new Y.Doc()
* const map = ydoc.getMap('map')
* // Log content when change is triggered
* map.observe(() => {
* console.log('change triggered')
* })
* // Each change on the map type triggers a log message:
* map.set('a', 0) // => "change triggered"
* map.set('b', 0) // => "change triggered"
* // When put in a transaction, it will trigger the log after the transaction:
* ydoc.transact(() => {
* map.set('a', 1)
* map.set('b', 1)
* }) // => "change triggered"
*
* @public
*/
export class Transaction {
/**
* @param {Doc} doc
* @param {any} origin
* @param {boolean} local
*/
constructor (doc, origin, local) {
/**
* The Yjs instance.
* @type {Doc}
*/
this.doc = doc
/**
* Describes the set of deleted items by ids
* @type {DeleteSet}
*/
this.deleteSet = new DeleteSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>}
*/
this.beforeState = getStateVector(doc.store)
/**
* Holds the state after the transaction.
* @type {Map<Number,Number>}
*/
this.afterState = new Map()
/**
* All types that were directly modified (property added or child
* inserted/deleted). New types are not included in this Set.
* Maps from type to parentSubs (`item.parentSub = null` for YArray)
* @type {Map<AbstractType<YEvent<any>>,Set<String|null>>}
*/
this.changed = new Map()
/**
* Stores the events for the types that observe also child elements.
* It is mainly used by `observeDeep`.
* @type {Map<AbstractType<YEvent<any>>,Array<YEvent<any>>>}
*/
this.changedParentTypes = new Map()
/**
* @type {Array<AbstractStruct>}
*/
this._mergeStructs = []
/**
* @type {any}
*/
this.origin = origin
/**
* Stores meta information on the transaction
* @type {Map<any,any>}
*/
this.meta = new Map()
/**
* Whether this change originates from this doc.
* @type {boolean}
*/
this.local = local
/**
* @type {Set<Doc>}
*/
this.subdocsAdded = new Set()
/**
* @type {Set<Doc>}
*/
this.subdocsRemoved = new Set()
/**
* @type {Set<Doc>}
*/
this.subdocsLoaded = new Set()
/**
* @type {boolean}
*/
this._needFormattingCleanup = false
}
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
* @return {boolean} Whether data was written.
*/
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
if (transaction.deleteSet.clients.size === 0 && !map.any(transaction.afterState, (clock, client) => transaction.beforeState.get(client) !== clock)) {
return false
}
sortAndMergeDeleteSet(transaction.deleteSet)
writeStructsFromTransaction(encoder, transaction)
writeDeleteSet(encoder, transaction.deleteSet)
return true
}
/**
* @param {Transaction} transaction
*
* @private
* @function
*/
export const nextID = transaction => {
const y = transaction.doc
return createID(y.clientID, getState(y.store, y.clientID))
}
/**
* If `type.parent` was added in current transaction, `type` technically
* did not change, it was just added and we should not fire events for `type`.
*
* @param {Transaction} transaction
* @param {AbstractType<YEvent<any>>} type
* @param {string|null} parentSub
*/
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
const item = type._item
if (item === null || (item.id.clock < (transaction.beforeState.get(item.id.client) || 0) && !item.deleted)) {
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
}
}
/**
* @param {Array<AbstractStruct>} structs
* @param {number} pos
* @return {number} # of merged structs
*/
const tryToMergeWithLefts = (structs, pos) => {
let right = structs[pos]
let left = structs[pos - 1]
let i = pos
for (; i > 0; right = left, left = structs[--i - 1]) {
if (left.deleted === right.deleted && left.constructor === right.constructor) {
if (left.mergeWith(right)) {
if (right instanceof Item && right.parentSub !== null && /** @type {AbstractType<any>} */ (right.parent)._map.get(right.parentSub) === right) {
/** @type {AbstractType<any>} */ (right.parent)._map.set(right.parentSub, /** @type {Item} */ (left))
}
continue
}
}
break
}
const merged = pos - i
if (merged) {
// remove all merged structs from the array
structs.splice(pos + 1 - merged, merged)
}
return merged
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
const tryGcDeleteSet = (ds, store, gcFilter) => {
for (const [client, deleteItems] of ds.clients.entries()) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
const endDeleteItemClock = deleteItem.clock + deleteItem.len
for (
let si = findIndexSS(structs, deleteItem.clock), struct = structs[si];
si < structs.length && struct.id.clock < endDeleteItemClock;
struct = structs[++si]
) {
const struct = structs[si]
if (deleteItem.clock + deleteItem.len <= struct.id.clock) {
break
}
if (struct instanceof Item && struct.deleted && !struct.keep && gcFilter(struct)) {
struct.gc(store, false)
}
}
}
}
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
*/
const tryMergeDeleteSet = (ds, store) => {
// try to merge deleted / gc'd items
// merge from right to left for better efficiency and so we don't miss any merge targets
ds.clients.forEach((deleteItems, client) => {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
for (let di = deleteItems.length - 1; di >= 0; di--) {
const deleteItem = deleteItems[di]
// start with merging the item next to the last deleted item
const mostRightIndexToCheck = math.min(structs.length - 1, 1 + findIndexSS(structs, deleteItem.clock + deleteItem.len - 1))
for (
let si = mostRightIndexToCheck, struct = structs[si];
si > 0 && struct.id.clock >= deleteItem.clock;
struct = structs[si]
) {
si -= 1 + tryToMergeWithLefts(structs, si)
}
}
})
}
/**
* @param {DeleteSet} ds
* @param {StructStore} store
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (ds, store, gcFilter) => {
tryGcDeleteSet(ds, store, gcFilter)
tryMergeDeleteSet(ds, store)
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
const mergeStructs = transaction._mergeStructs
try {
sortAndMergeDeleteSet(ds)
transaction.afterState = getStateVector(transaction.doc.store)
doc.emit('beforeObserverCalls', [transaction, doc])
/**
* An array of event callbacks.
*
* Each callback is called even if the other ones throw errors.
*
* @type {Array<function():void>}
*/
const fs = []
// observe events on changed types
transaction.changed.forEach((subs, itemtype) =>
fs.push(() => {
if (itemtype._item === null || !itemtype._item.deleted) {
itemtype._callObserver(transaction, subs)
}
})
)
fs.push(() => {
// deep observe events
transaction.changedParentTypes.forEach((events, type) => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
if (type._dEH.l.length > 0 && (type._item === null || !type._item.deleted)) {
events = events
.filter(event =>
event.target._item === null || !event.target._item.deleted
)
events
.forEach(event => {
event.currentTarget = type
// path is relative to the current target
event._path = null
})
// 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)
}
})
})
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
callAll(fs, [])
if (transaction._needFormattingCleanup) {
cleanupYTextAfterTransaction(transaction)
}
} finally {
// Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc.
if (doc.gc) {
tryGcDeleteSet(ds, store, doc.gcFilter)
}
tryMergeDeleteSet(ds, store)
// on all affected store.clients props, try to merge
transaction.afterState.forEach((clock, client) => {
const beforeClock = transaction.beforeState.get(client) || 0
if (beforeClock !== clock) {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
// we iterate from right to left so we can safely remove entries
const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1)
for (let i = structs.length - 1; i >= firstChangePos;) {
i -= 1 + tryToMergeWithLefts(structs, i)
}
}
})
// try to merge mergeStructs
// @todo: it makes more sense to transform mergeStructs to a DS, sort it, and merge from right to left
// but at the moment DS does not handle duplicates
for (let i = mergeStructs.length - 1; i >= 0; i--) {
const { client, clock } = mergeStructs[i].id
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
const replacedStructPos = findIndexSS(structs, clock)
if (replacedStructPos + 1 < structs.length) {
if (tryToMergeWithLefts(structs, replacedStructPos + 1) > 1) {
continue // no need to perform next check, both are already merged
}
}
if (replacedStructPos > 0) {
tryToMergeWithLefts(structs, replacedStructPos)
}
}
if (!transaction.local && transaction.afterState.get(doc.clientID) !== transaction.beforeState.get(doc.clientID)) {
logging.print(logging.ORANGE, logging.BOLD, '[yjs] ', logging.UNBOLD, logging.RED, 'Changed the client-id because another client seems to be using it.')
doc.clientID = generateNewClientId()
}
// @todo Merge all the transactions into one and provide send the data as a single update message
doc.emit('afterTransactionCleanup', [transaction, doc])
if (doc._observers.has('update')) {
const encoder = new UpdateEncoderV1()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('update', [encoder.toUint8Array(), transaction.origin, doc, transaction])
}
}
if (doc._observers.has('updateV2')) {
const encoder = new UpdateEncoderV2()
const hasContent = writeUpdateMessageFromTransaction(encoder, transaction)
if (hasContent) {
doc.emit('updateV2', [encoder.toUint8Array(), transaction.origin, doc, transaction])
}
}
const { subdocsAdded, subdocsLoaded, subdocsRemoved } = transaction
if (subdocsAdded.size > 0 || subdocsRemoved.size > 0 || subdocsLoaded.size > 0) {
subdocsAdded.forEach(subdoc => {
subdoc.clientID = doc.clientID
if (subdoc.collectionid == null) {
subdoc.collectionid = doc.collectionid
}
doc.subdocs.add(subdoc)
})
subdocsRemoved.forEach(subdoc => doc.subdocs.delete(subdoc))
doc.emit('subdocs', [{ loaded: subdocsLoaded, added: subdocsAdded, removed: subdocsRemoved }, doc, transaction])
subdocsRemoved.forEach(subdoc => subdoc.destroy())
}
if (transactionCleanups.length <= i + 1) {
doc._transactionCleanups = []
doc.emit('afterAllTransactions', [doc, transactionCleanups])
} else {
cleanupTransactions(transactionCleanups, i + 1)
}
}
}
}
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @template T
* @param {Doc} doc
* @param {function(Transaction):T} f
* @param {any} [origin=true]
* @return {T}
*
* @function
*/
export const transact = (doc, f, origin = null, local = true) => {
const transactionCleanups = doc._transactionCleanups
let initialCall = false
/**
* @type {any}
*/
let result = null
if (doc._transaction === null) {
initialCall = true
doc._transaction = new Transaction(doc, origin, local)
transactionCleanups.push(doc._transaction)
if (transactionCleanups.length === 1) {
doc.emit('beforeAllTransactions', [doc])
}
doc.emit('beforeTransaction', [doc._transaction, doc])
}
try {
result = f(doc._transaction)
} finally {
if (initialCall) {
const finishCleanup = doc._transaction === transactionCleanups[0]
doc._transaction = null
if (finishCleanup) {
// The first transaction ended, now process observer calls.
// Observer call may create new transactions for which we need to call the observers and do cleanup.
// We don't want to nest these calls, so we execute these calls one after
// another.
// Also we need to ensure that all cleanups are called, even if the
// observes throw errors.
// This file is full of hacky try {} finally {} blocks to ensure that an
// event can throw errors and also that the cleanup is called.
cleanupTransactions(transactionCleanups, 0)
}
}
}
return result
}

View File

@ -1,400 +0,0 @@
import {
mergeDeleteSets,
iterateDeletedStructs,
keepItem,
transact,
createID,
redoItem,
isParentOf,
followRedone,
getItemCleanStart,
isDeleted,
addToDeleteSet,
YEvent, Transaction, Doc, Item, GC, DeleteSet, AbstractType // eslint-disable-line
} from '../internals.js'
import * as time from 'lib0/time'
import * as array from 'lib0/array'
import * as logging from 'lib0/logging'
import { ObservableV2 } from 'lib0/observable'
export class StackItem {
/**
* @param {DeleteSet} deletions
* @param {DeleteSet} insertions
*/
constructor (deletions, insertions) {
this.insertions = insertions
this.deletions = deletions
/**
* Use this to save and restore metadata like selection range
*/
this.meta = new Map()
}
}
/**
* @param {Transaction} tr
* @param {UndoManager} um
* @param {StackItem} stackItem
*/
const clearUndoManagerStackItem = (tr, um, stackItem) => {
iterateDeletedStructs(tr, stackItem.deletions, item => {
if (item instanceof Item && um.scope.some(type => type === tr.doc || isParentOf(/** @type {AbstractType<any>} */ (type), item))) {
keepItem(item, false)
}
})
}
/**
* @param {UndoManager} undoManager
* @param {Array<StackItem>} stack
* @param {'undo'|'redo'} eventType
* @return {StackItem?}
*/
const popStackItem = (undoManager, stack, eventType) => {
/**
* Keep a reference to the transaction so we can fire the event with the changedParentTypes
* @type {any}
*/
let _tr = null
const doc = undoManager.doc
const scope = undoManager.scope
transact(doc, transaction => {
while (stack.length > 0 && undoManager.currStackItem === null) {
const store = doc.store
const stackItem = /** @type {StackItem} */ (stack.pop())
/**
* @type {Set<Item>}
*/
const itemsToRedo = new Set()
/**
* @type {Array<Item>}
*/
const itemsToDelete = []
let performedChange = false
iterateDeletedStructs(transaction, stackItem.insertions, struct => {
if (struct instanceof Item) {
if (struct.redone !== null) {
let { item, diff } = followRedone(store, struct.id)
if (diff > 0) {
item = getItemCleanStart(transaction, createID(item.id.client, item.id.clock + diff))
}
struct = item
}
if (!struct.deleted && scope.some(type => type === transaction.doc || isParentOf(/** @type {AbstractType<any>} */ (type), /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
iterateDeletedStructs(transaction, stackItem.deletions, struct => {
if (
struct instanceof Item &&
scope.some(type => type === transaction.doc || isParentOf(/** @type {AbstractType<any>} */ (type), struct)) &&
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
!isDeleted(stackItem.insertions, struct.id)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.insertions, undoManager.ignoreRemoteMapChanges, undoManager) !== null || performedChange
})
// We want to delete in reverse order so that children are deleted before
// parents, so we have more information available when items are filtered.
for (let i = itemsToDelete.length - 1; i >= 0; i--) {
const item = itemsToDelete[i]
if (undoManager.deleteFilter(item)) {
item.delete(transaction)
performedChange = true
}
}
undoManager.currStackItem = performedChange ? stackItem : null
}
transaction.changed.forEach((subProps, type) => {
// destroy search marker if necessary
if (subProps.has(null) && type._searchMarker) {
type._searchMarker.length = 0
}
})
_tr = transaction
}, undoManager)
const res = undoManager.currStackItem
if (res != null) {
const changedParentTypes = _tr.changedParentTypes
undoManager.emit('stack-item-popped', [{ stackItem: res, type: eventType, changedParentTypes, origin: undoManager }, undoManager])
undoManager.currStackItem = null
}
return res
}
/**
* @typedef {Object} UndoManagerOptions
* @property {number} [UndoManagerOptions.captureTimeout=500]
* @property {function(Transaction):boolean} [UndoManagerOptions.captureTransaction] Do not capture changes of a Transaction if result false.
* @property {function(Item):boolean} [UndoManagerOptions.deleteFilter=()=>true] Sometimes
* it is necessary to filter what an Undo/Redo operation can delete. If this
* filter returns false, the type/item won't be deleted even it is in the
* undo/redo scope.
* @property {Set<any>} [UndoManagerOptions.trackedOrigins=new Set([null])]
* @property {boolean} [ignoreRemoteMapChanges] Experimental. By default, the UndoManager will never overwrite remote changes. Enable this property to enable overwriting remote changes on key-value changes (Y.Map, properties on Y.Xml, etc..).
* @property {Doc} [doc] The document that this UndoManager operates on. Only needed if typeScope is empty.
*/
/**
* @typedef {Object} StackItemEvent
* @property {StackItem} StackItemEvent.stackItem
* @property {any} StackItemEvent.origin
* @property {'undo'|'redo'} StackItemEvent.type
* @property {Map<AbstractType<YEvent<any>>,Array<YEvent<any>>>} StackItemEvent.changedParentTypes
*/
/**
* Fires 'stack-item-added' event when a stack item was added to either the undo- or
* the redo-stack. You may store additional stack information via the
* metadata property on `event.stackItem.meta` (it is a `Map` of metadata properties).
* Fires 'stack-item-popped' event when a stack item was popped from either the
* undo- or the redo-stack. You may restore the saved stack information from `event.stackItem.meta`.
*
* @extends {ObservableV2<{'stack-item-added':function(StackItemEvent, UndoManager):void, 'stack-item-popped': function(StackItemEvent, UndoManager):void, 'stack-cleared': function({ undoStackCleared: boolean, redoStackCleared: boolean }):void, 'stack-item-updated': function(StackItemEvent, UndoManager):void }>}
*/
export class UndoManager extends ObservableV2 {
/**
* @param {Doc|AbstractType<any>|Array<AbstractType<any>>} typeScope Limits the scope of the UndoManager. If this is set to a ydoc instance, all changes on that ydoc will be undone. If set to a specific type, only changes on that type or its children will be undone. Also accepts an array of types.
* @param {UndoManagerOptions} options
*/
constructor (typeScope, {
captureTimeout = 500,
captureTransaction = _tr => true,
deleteFilter = () => true,
trackedOrigins = new Set([null]),
ignoreRemoteMapChanges = false,
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc)
} = {}) {
super()
/**
* @type {Array<AbstractType<any> | Doc>}
*/
this.scope = []
this.doc = doc
this.addToScope(typeScope)
this.deleteFilter = deleteFilter
trackedOrigins.add(this)
this.trackedOrigins = trackedOrigins
this.captureTransaction = captureTransaction
/**
* @type {Array<StackItem>}
*/
this.undoStack = []
/**
* @type {Array<StackItem>}
*/
this.redoStack = []
/**
* Whether the client is currently undoing (calling UndoManager.undo)
*
* @type {boolean}
*/
this.undoing = false
this.redoing = false
/**
* The currently popped stack item if UndoManager.undoing or UndoManager.redoing
*
* @type {StackItem|null}
*/
this.currStackItem = null
this.lastChange = 0
this.ignoreRemoteMapChanges = ignoreRemoteMapChanges
this.captureTimeout = captureTimeout
/**
* @param {Transaction} transaction
*/
this.afterTransactionHandler = transaction => {
// Only track certain transactions
if (
!this.captureTransaction(transaction) ||
!this.scope.some(type => transaction.changedParentTypes.has(/** @type {AbstractType<any>} */ (type)) || type === this.doc) ||
(!this.trackedOrigins.has(transaction.origin) && (!transaction.origin || !this.trackedOrigins.has(transaction.origin.constructor)))
) {
return
}
const undoing = this.undoing
const redoing = this.redoing
const stack = undoing ? this.redoStack : this.undoStack
if (undoing) {
this.stopCapturing() // next undo should not be appended to last stack item
} else if (!redoing) {
// neither undoing nor redoing: delete redoStack
this.clear(false, true)
}
const insertions = new DeleteSet()
transaction.afterState.forEach((endClock, client) => {
const startClock = transaction.beforeState.get(client) || 0
const len = endClock - startClock
if (len > 0) {
addToDeleteSet(insertions, client, startClock, len)
}
})
const now = time.getUnixTime()
let didAdd = false
if (this.lastChange > 0 && now - this.lastChange < this.captureTimeout && stack.length > 0 && !undoing && !redoing) {
// append change to last stack op
const lastOp = stack[stack.length - 1]
lastOp.deletions = mergeDeleteSets([lastOp.deletions, transaction.deleteSet])
lastOp.insertions = mergeDeleteSets([lastOp.insertions, insertions])
} else {
// create a new stack op
stack.push(new StackItem(transaction.deleteSet, insertions))
didAdd = true
}
if (!undoing && !redoing) {
this.lastChange = now
}
// make sure that deleted structs are not gc'd
iterateDeletedStructs(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => type === transaction.doc || isParentOf(/** @type {AbstractType<any>} */ (type), item))) {
keepItem(item, true)
}
})
/**
* @type {[StackItemEvent, UndoManager]}
*/
const changeEvent = [{ stackItem: stack[stack.length - 1], origin: transaction.origin, type: undoing ? 'redo' : 'undo', changedParentTypes: transaction.changedParentTypes }, this]
if (didAdd) {
this.emit('stack-item-added', changeEvent)
} else {
this.emit('stack-item-updated', changeEvent)
}
}
this.doc.on('afterTransaction', this.afterTransactionHandler)
this.doc.on('destroy', () => {
this.destroy()
})
}
/**
* Extend the scope.
*
* @param {Array<AbstractType<any> | Doc> | AbstractType<any> | Doc} ytypes
*/
addToScope (ytypes) {
const tmpSet = new Set(this.scope)
ytypes = array.isArray(ytypes) ? ytypes : [ytypes]
ytypes.forEach(ytype => {
if (!tmpSet.has(ytype)) {
tmpSet.add(ytype)
if (ytype instanceof AbstractType ? ytype.doc !== this.doc : ytype !== this.doc) logging.warn('[yjs#509] Not same Y.Doc') // use MultiDocUndoManager instead. also see https://github.com/yjs/yjs/issues/509
this.scope.push(ytype)
}
})
}
/**
* @param {any} origin
*/
addTrackedOrigin (origin) {
this.trackedOrigins.add(origin)
}
/**
* @param {any} origin
*/
removeTrackedOrigin (origin) {
this.trackedOrigins.delete(origin)
}
clear (clearUndoStack = true, clearRedoStack = true) {
if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) {
this.doc.transact(tr => {
if (clearUndoStack) {
this.undoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.undoStack = []
}
if (clearRedoStack) {
this.redoStack.forEach(item => clearUndoManagerStackItem(tr, this, item))
this.redoStack = []
}
this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }])
})
}
}
/**
* UndoManager merges Undo-StackItem if they are created within time-gap
* smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next
* StackItem won't be merged.
*
*
* @example
* // without stopCapturing
* ytext.insert(0, 'a')
* ytext.insert(1, 'b')
* um.undo()
* ytext.toString() // => '' (note that 'ab' was removed)
* // with stopCapturing
* ytext.insert(0, 'a')
* um.stopCapturing()
* ytext.insert(0, 'b')
* um.undo()
* ytext.toString() // => 'a' (note that only 'b' was removed)
*
*/
stopCapturing () {
this.lastChange = 0
}
/**
* Undo last changes on type.
*
* @return {StackItem?} Returns StackItem if a change was applied
*/
undo () {
this.undoing = true
let res
try {
res = popStackItem(this, this.undoStack, 'undo')
} finally {
this.undoing = false
}
return res
}
/**
* Redo last undo operation.
*
* @return {StackItem?} Returns StackItem if a change was applied
*/
redo () {
this.redoing = true
let res
try {
res = popStackItem(this, this.redoStack, 'redo')
} finally {
this.redoing = false
}
return res
}
/**
* Are undo steps available?
*
* @return {boolean} `true` if undo is possible
*/
canUndo () {
return this.undoStack.length > 0
}
/**
* Are redo steps available?
*
* @return {boolean} `true` if redo is possible
*/
canRedo () {
return this.redoStack.length > 0
}
destroy () {
this.trackedOrigins.delete(this)
this.doc.off('afterTransaction', this.afterTransactionHandler)
super.destroy()
}
}

View File

@ -1,281 +0,0 @@
import * as buffer from 'lib0/buffer'
import * as decoding from 'lib0/decoding'
import {
ID, createID
} from '../internals.js'
export class DSDecoderV1 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
this.restDecoder = decoder
}
resetDsCurVal () {
// nop
}
/**
* @return {number}
*/
readDsClock () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {number}
*/
readDsLen () {
return decoding.readVarUint(this.restDecoder)
}
}
export class UpdateDecoderV1 extends DSDecoderV1 {
/**
* @return {ID}
*/
readLeftID () {
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
}
/**
* @return {ID}
*/
readRightID () {
return createID(decoding.readVarUint(this.restDecoder), decoding.readVarUint(this.restDecoder))
}
/**
* Read the next client id.
* Use this in favor of readID whenever possible to reduce the number of objects created.
*/
readClient () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readInfo () {
return decoding.readUint8(this.restDecoder)
}
/**
* @return {string}
*/
readString () {
return decoding.readVarString(this.restDecoder)
}
/**
* @return {boolean} isKey
*/
readParentInfo () {
return decoding.readVarUint(this.restDecoder) === 1
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readTypeRef () {
return decoding.readVarUint(this.restDecoder)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @return {number} len
*/
readLen () {
return decoding.readVarUint(this.restDecoder)
}
/**
* @return {any}
*/
readAny () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {Uint8Array}
*/
readBuf () {
return buffer.copyUint8Array(decoding.readVarUint8Array(this.restDecoder))
}
/**
* Legacy implementation uses JSON parse. We use any-decoding in v2.
*
* @return {any}
*/
readJSON () {
return JSON.parse(decoding.readVarString(this.restDecoder))
}
/**
* @return {string}
*/
readKey () {
return decoding.readVarString(this.restDecoder)
}
}
export class DSDecoderV2 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
/**
* @private
*/
this.dsCurrVal = 0
this.restDecoder = decoder
}
resetDsCurVal () {
this.dsCurrVal = 0
}
/**
* @return {number}
*/
readDsClock () {
this.dsCurrVal += decoding.readVarUint(this.restDecoder)
return this.dsCurrVal
}
/**
* @return {number}
*/
readDsLen () {
const diff = decoding.readVarUint(this.restDecoder) + 1
this.dsCurrVal += diff
return diff
}
}
export class UpdateDecoderV2 extends DSDecoderV2 {
/**
* @param {decoding.Decoder} decoder
*/
constructor (decoder) {
super(decoder)
/**
* List of cached keys. If the keys[id] does not exist, we read a new key
* from stringEncoder and push it to keys.
*
* @type {Array<string>}
*/
this.keys = []
decoding.readVarUint(decoder) // read feature flag - currently unused
this.keyClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.clientDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
this.leftClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.rightClockDecoder = new decoding.IntDiffOptRleDecoder(decoding.readVarUint8Array(decoder))
this.infoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
this.stringDecoder = new decoding.StringDecoder(decoding.readVarUint8Array(decoder))
this.parentInfoDecoder = new decoding.RleDecoder(decoding.readVarUint8Array(decoder), decoding.readUint8)
this.typeRefDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
this.lenDecoder = new decoding.UintOptRleDecoder(decoding.readVarUint8Array(decoder))
}
/**
* @return {ID}
*/
readLeftID () {
return new ID(this.clientDecoder.read(), this.leftClockDecoder.read())
}
/**
* @return {ID}
*/
readRightID () {
return new ID(this.clientDecoder.read(), this.rightClockDecoder.read())
}
/**
* Read the next client id.
* Use this in favor of readID whenever possible to reduce the number of objects created.
*/
readClient () {
return this.clientDecoder.read()
}
/**
* @return {number} info An unsigned 8-bit integer
*/
readInfo () {
return /** @type {number} */ (this.infoDecoder.read())
}
/**
* @return {string}
*/
readString () {
return this.stringDecoder.read()
}
/**
* @return {boolean}
*/
readParentInfo () {
return this.parentInfoDecoder.read() === 1
}
/**
* @return {number} An unsigned 8-bit integer
*/
readTypeRef () {
return this.typeRefDecoder.read()
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @return {number}
*/
readLen () {
return this.lenDecoder.read()
}
/**
* @return {any}
*/
readAny () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {Uint8Array}
*/
readBuf () {
return decoding.readVarUint8Array(this.restDecoder)
}
/**
* This is mainly here for legacy purposes.
*
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
*
* @return {any}
*/
readJSON () {
return decoding.readAny(this.restDecoder)
}
/**
* @return {string}
*/
readKey () {
const keyClock = this.keyClockDecoder.read()
if (keyClock < this.keys.length) {
return this.keys[keyClock]
} else {
const key = this.stringDecoder.read()
this.keys.push(key)
return key
}
}
}

View File

@ -1,320 +0,0 @@
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding'
import {
ID // eslint-disable-line
} from '../internals.js'
export class DSEncoderV1 {
constructor () {
this.restEncoder = encoding.createEncoder()
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetDsCurVal () {
// nop
}
/**
* @param {number} clock
*/
writeDsClock (clock) {
encoding.writeVarUint(this.restEncoder, clock)
}
/**
* @param {number} len
*/
writeDsLen (len) {
encoding.writeVarUint(this.restEncoder, len)
}
}
export class UpdateEncoderV1 extends DSEncoderV1 {
/**
* @param {ID} id
*/
writeLeftID (id) {
encoding.writeVarUint(this.restEncoder, id.client)
encoding.writeVarUint(this.restEncoder, id.clock)
}
/**
* @param {ID} id
*/
writeRightID (id) {
encoding.writeVarUint(this.restEncoder, id.client)
encoding.writeVarUint(this.restEncoder, id.clock)
}
/**
* Use writeClient and writeClock instead of writeID if possible.
* @param {number} client
*/
writeClient (client) {
encoding.writeVarUint(this.restEncoder, client)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeInfo (info) {
encoding.writeUint8(this.restEncoder, info)
}
/**
* @param {string} s
*/
writeString (s) {
encoding.writeVarString(this.restEncoder, s)
}
/**
* @param {boolean} isYKey
*/
writeParentInfo (isYKey) {
encoding.writeVarUint(this.restEncoder, isYKey ? 1 : 0)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeTypeRef (info) {
encoding.writeVarUint(this.restEncoder, info)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @param {number} len
*/
writeLen (len) {
encoding.writeVarUint(this.restEncoder, len)
}
/**
* @param {any} any
*/
writeAny (any) {
encoding.writeAny(this.restEncoder, any)
}
/**
* @param {Uint8Array} buf
*/
writeBuf (buf) {
encoding.writeVarUint8Array(this.restEncoder, buf)
}
/**
* @param {any} embed
*/
writeJSON (embed) {
encoding.writeVarString(this.restEncoder, JSON.stringify(embed))
}
/**
* @param {string} key
*/
writeKey (key) {
encoding.writeVarString(this.restEncoder, key)
}
}
export class DSEncoderV2 {
constructor () {
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
this.dsCurrVal = 0
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetDsCurVal () {
this.dsCurrVal = 0
}
/**
* @param {number} clock
*/
writeDsClock (clock) {
const diff = clock - this.dsCurrVal
this.dsCurrVal = clock
encoding.writeVarUint(this.restEncoder, diff)
}
/**
* @param {number} len
*/
writeDsLen (len) {
if (len === 0) {
error.unexpectedCase()
}
encoding.writeVarUint(this.restEncoder, len - 1)
this.dsCurrVal += len
}
}
export class UpdateEncoderV2 extends DSEncoderV2 {
constructor () {
super()
/**
* @type {Map<string,number>}
*/
this.keyMap = new Map()
/**
* Refers to the next unique key-identifier to me used.
* See writeKey method for more information.
*
* @type {number}
*/
this.keyClock = 0
this.keyClockEncoder = new encoding.IntDiffOptRleEncoder()
this.clientEncoder = new encoding.UintOptRleEncoder()
this.leftClockEncoder = new encoding.IntDiffOptRleEncoder()
this.rightClockEncoder = new encoding.IntDiffOptRleEncoder()
this.infoEncoder = new encoding.RleEncoder(encoding.writeUint8)
this.stringEncoder = new encoding.StringEncoder()
this.parentInfoEncoder = new encoding.RleEncoder(encoding.writeUint8)
this.typeRefEncoder = new encoding.UintOptRleEncoder()
this.lenEncoder = new encoding.UintOptRleEncoder()
}
toUint8Array () {
const encoder = encoding.createEncoder()
encoding.writeVarUint(encoder, 0) // this is a feature flag that we might use in the future
encoding.writeVarUint8Array(encoder, this.keyClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.clientEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.leftClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.rightClockEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.infoEncoder))
encoding.writeVarUint8Array(encoder, this.stringEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, encoding.toUint8Array(this.parentInfoEncoder))
encoding.writeVarUint8Array(encoder, this.typeRefEncoder.toUint8Array())
encoding.writeVarUint8Array(encoder, this.lenEncoder.toUint8Array())
// @note The rest encoder is appended! (note the missing var)
encoding.writeUint8Array(encoder, encoding.toUint8Array(this.restEncoder))
return encoding.toUint8Array(encoder)
}
/**
* @param {ID} id
*/
writeLeftID (id) {
this.clientEncoder.write(id.client)
this.leftClockEncoder.write(id.clock)
}
/**
* @param {ID} id
*/
writeRightID (id) {
this.clientEncoder.write(id.client)
this.rightClockEncoder.write(id.clock)
}
/**
* @param {number} client
*/
writeClient (client) {
this.clientEncoder.write(client)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeInfo (info) {
this.infoEncoder.write(info)
}
/**
* @param {string} s
*/
writeString (s) {
this.stringEncoder.write(s)
}
/**
* @param {boolean} isYKey
*/
writeParentInfo (isYKey) {
this.parentInfoEncoder.write(isYKey ? 1 : 0)
}
/**
* @param {number} info An unsigned 8-bit integer
*/
writeTypeRef (info) {
this.typeRefEncoder.write(info)
}
/**
* Write len of a struct - well suited for Opt RLE encoder.
*
* @param {number} len
*/
writeLen (len) {
this.lenEncoder.write(len)
}
/**
* @param {any} any
*/
writeAny (any) {
encoding.writeAny(this.restEncoder, any)
}
/**
* @param {Uint8Array} buf
*/
writeBuf (buf) {
encoding.writeVarUint8Array(this.restEncoder, buf)
}
/**
* This is mainly here for legacy purposes.
*
* Initial we incoded objects using JSON. Now we use the much faster lib0/any-encoder. This method mainly exists for legacy purposes for the v1 encoder.
*
* @param {any} embed
*/
writeJSON (embed) {
encoding.writeAny(this.restEncoder, embed)
}
/**
* Property keys are often reused. For example, in y-prosemirror the key `bold` might
* occur very often. For a 3d application, the key `position` might occur very often.
*
* We cache these keys in a Map and refer to them via a unique number.
*
* @param {string} key
*/
writeKey (key) {
const clock = this.keyMap.get(key)
if (clock === undefined) {
/**
* @todo uncomment to introduce this feature finally
*
* Background. The ContentFormat object was always encoded using writeKey, but the decoder used to use readString.
* Furthermore, I forgot to set the keyclock. So everything was working fine.
*
* However, this feature here is basically useless as it is not being used (it actually only consumes extra memory).
*
* I don't know yet how to reintroduce this feature..
*
* Older clients won't be able to read updates when we reintroduce this feature. So this should probably be done using a flag.
*
*/
// this.keyMap.set(key, this.keyClock)
this.keyClockEncoder.write(this.keyClock++)
this.stringEncoder.write(key)
} else {
this.keyClockEncoder.write(clock)
}
}
}

View File

@ -1,277 +0,0 @@
import {
isDeleted,
Item, AbstractType, Transaction, AbstractStruct // eslint-disable-line
} from '../internals.js'
import * as set from 'lib0/set'
import * as array from 'lib0/array'
import * as error from 'lib0/error'
const errorComputeChanges = 'You must not compute changes after the event-handler fired.'
/**
* @template {AbstractType<any>} T
* YEvent describes the changes on a YType.
*/
export class YEvent {
/**
* @param {T} target The changed type.
* @param {Transaction} transaction
*/
constructor (target, transaction) {
/**
* The type on which this event was created on.
* @type {T}
*/
this.target = target
/**
* The current target on which the observe callback is called.
* @type {AbstractType<any>}
*/
this.currentTarget = target
/**
* The transaction that triggered this event.
* @type {Transaction}
*/
this.transaction = transaction
/**
* @type {Object|null}
*/
this._changes = null
/**
* @type {null | Map<string, { action: 'add' | 'update' | 'delete', oldValue: any, newValue: any }>}
*/
this._keys = null
/**
* @type {null | Array<{ insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any> }>}
*/
this._delta = null
/**
* @type {Array<string|number>|null}
*/
this._path = null
}
/**
* Computes the path from `y` to the changed type.
*
* @todo v14 should standardize on path: Array<{parent, index}> because that is easier to work with.
*
* The following property holds:
* @example
* let type = y
* event.path.forEach(dir => {
* type = type.get(dir)
* })
* type === event.target // => true
*/
get path () {
return this._path || (this._path = getPathTo(this.currentTarget, this.target))
}
/**
* Check if a struct is deleted by this event.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
deletes (struct) {
return isDeleted(this.transaction.deleteSet, struct.id)
}
/**
* @type {Map<string, { action: 'add' | 'update' | 'delete', oldValue: any, newValue: any }>}
*/
get keys () {
if (this._keys === null) {
if (this.transaction.doc._transactionCleanups.length === 0) {
throw error.create(errorComputeChanges)
}
const keys = new Map()
const target = this.target
const changed = /** @type Set<string|null> */ (this.transaction.changed.get(target))
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._keys = keys
}
return this._keys
}
/**
* This is a computed property. Note that this can only be safely computed during the
* event call. Computing this property after other changes happened might result in
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
*
* @type {Array<{insert?: string | Array<any> | object | AbstractType<any>, retain?: number, delete?: number, attributes?: Object<string, any>}>}
*/
get delta () {
return this.changes.delta
}
/**
* Check if a struct is added by this event.
*
* In contrast to change.deleted, this method also returns true if the struct was added and then deleted.
*
* @param {AbstractStruct} struct
* @return {boolean}
*/
adds (struct) {
return struct.id.clock >= (this.transaction.beforeState.get(struct.id.client) || 0)
}
/**
* This is a computed property. Note that this can only be safely computed during the
* event call. Computing this property after other changes happened might result in
* unexpected behavior (incorrect computation of deltas). A safe way to collect changes
* is to store the `changes` or the `delta` object. Avoid storing the `transaction` object.
*
* @type {{added:Set<Item>,deleted:Set<Item>,keys:Map<string,{action:'add'|'update'|'delete',oldValue:any}>,delta:Array<{insert?:Array<any>|string, delete?:number, retain?:number}>}}
*/
get changes () {
let changes = this._changes
if (changes === null) {
if (this.transaction.doc._transactionCleanups.length === 0) {
throw error.create(errorComputeChanges)
}
const target = this.target
const added = set.create()
const deleted = set.create()
/**
* @type {Array<{insert:Array<any>}|{delete:number}|{retain:number}>}
*/
const delta = []
changes = {
added,
deleted,
delta,
keys: this.keys
}
const changed = /** @type Set<string|null> */ (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) && !this.adds(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()
}
}
this._changes = changes
}
return /** @type {any} */ (changes)
}
}
/**
* Compute the path from this type to the specified target.
*
* @example
* // `child` should be accessible via `type.get(path[0]).get(path[1])..`
* const path = type.getPathTo(child)
* // assuming `type instanceof YArray`
* console.log(path) // might look like => [2, 'key1']
* child === type.get(path[0]).get(path[1])
*
* @param {AbstractType<any>} parent
* @param {AbstractType<any>} child target
* @return {Array<string|number>} Path to the target
*
* @private
* @function
*/
const getPathTo = (parent, child) => {
const path = []
while (child._item !== null && child !== parent) {
if (child._item.parentSub !== null) {
// parent is map-ish
path.unshift(child._item.parentSub)
} else {
// parent is array-ish
let i = 0
let c = /** @type {AbstractType<any>} */ (child._item.parent)._start
while (c !== child._item && c !== null) {
if (!c.deleted && c.countable) {
i += c.length
}
c = c.right
}
path.unshift(i)
}
child = /** @type {AbstractType<any>} */ (child._item.parent)
}
return path
}

View File

@ -1,644 +0,0 @@
/**
* @module encoding
*/
/*
* We use the first five bits in the info flag for determining the type of the struct.
*
* 0: GC
* 1: Item with Deleted content
* 2: Item with JSON content
* 3: Item with Binary content
* 4: Item with String content
* 5: Item with Embed content (for richtext content)
* 6: Item with Format content (a formatting marker for richtext content)
* 7: Item with Type
*/
import {
findIndexSS,
getState,
createID,
getStateVector,
readAndApplyDeleteSet,
writeDeleteSet,
createDeleteSetFromStructStore,
transact,
readItemContent,
UpdateDecoderV1,
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
DSEncoderV2,
DSDecoderV1,
DSEncoderV1,
mergeUpdates,
mergeUpdatesV2,
Skip,
diffUpdateV2,
convertUpdateFormatV2ToV1,
DSDecoderV2, Doc, Transaction, GC, Item, StructStore // eslint-disable-line
} from '../internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as binary from 'lib0/binary'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as array from 'lib0/array'
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Array<GC|Item>} structs All structs by `client`
* @param {number} client
* @param {number} clock write structs starting with `ID(client,clock)`
*
* @function
*/
const writeStructs = (encoder, structs, client, clock) => {
// write first id
clock = math.max(clock, structs[0].id.clock) // make sure the first id exists
const startNewStructs = findIndexSS(structs, clock)
// write # encoded structs
encoding.writeVarUint(encoder.restEncoder, structs.length - startNewStructs)
encoder.writeClient(client)
encoding.writeVarUint(encoder.restEncoder, clock)
const firstStruct = structs[startNewStructs]
// write first struct with an offset
firstStruct.write(encoder, clock - firstStruct.id.clock)
for (let i = startNewStructs + 1; i < structs.length; i++) {
structs[i].write(encoder, 0)
}
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {StructStore} store
* @param {Map<number,number>} _sm
*
* @private
* @function
*/
export const writeClientsStructs = (encoder, store, _sm) => {
// we filter all valid _sm entries into sm
const sm = new Map()
_sm.forEach((clock, client) => {
// only write if new structs are available
if (getState(store, client) > clock) {
sm.set(client, clock)
}
})
getStateVector(store).forEach((_clock, client) => {
if (!_sm.has(client)) {
sm.set(client, 0)
}
})
// write # states that were updated
encoding.writeVarUint(encoder.restEncoder, sm.size)
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
array.from(sm.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
writeStructs(encoder, /** @type {Array<GC|Item>} */ (store.clients.get(client)), client, clock)
})
}
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
* @param {Doc} doc
* @return {Map<number, { i: number, refs: Array<Item | GC> }>}
*
* @private
* @function
*/
export const readClientsStructRefs = (decoder, doc) => {
/**
* @type {Map<number, { i: number, refs: Array<Item | GC> }>}
*/
const clientRefs = map.create()
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
/**
* @type {Array<GC|Item>}
*/
const refs = new Array(numberOfStructs)
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
// const start = performance.now()
clientRefs.set(client, { i: 0, refs })
for (let i = 0; i < numberOfStructs; i++) {
const info = decoder.readInfo()
switch (binary.BITS5 & info) {
case 0: { // GC
const len = decoder.readLen()
refs[i] = new GC(createID(client, clock), len)
clock += len
break
}
case 10: { // Skip Struct (nothing to apply)
// @todo we could reduce the amount of checks by adding Skip struct to clientRefs so we know that something is missing.
const len = decoding.readVarUint(decoder.restDecoder)
refs[i] = new Skip(createID(client, clock), len)
clock += len
break
}
default: { // Item with content
/**
* The optimized implementation doesn't use any variables because inlining variables is faster.
* Below a non-optimized version is shown that implements the basic algorithm with
* a few comments
*/
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const struct = new Item(
createID(client, clock),
null, // left
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
null, // right
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
cantCopyParentInfo ? (decoder.readParentInfo() ? doc.get(decoder.readString()) : decoder.readLeftID()) : null, // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
/* A non-optimized implementation of the above algorithm:
// The item that was originally to the left of this item.
const origin = (info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null
// The item that was originally to the right of this item.
const rightOrigin = (info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
const hasParentYKey = cantCopyParentInfo ? decoder.readParentInfo() : false
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const parentYKey = cantCopyParentInfo && hasParentYKey ? decoder.readString() : null
const struct = new Item(
createID(client, clock),
null, // left
origin, // origin
null, // right
rightOrigin, // right origin
cantCopyParentInfo && !hasParentYKey ? decoder.readLeftID() : (parentYKey !== null ? doc.get(parentYKey) : null), // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
*/
refs[i] = struct
clock += struct.length
}
}
}
// console.log('time to read: ', performance.now() - start) // @todo remove
}
return clientRefs
}
/**
* Resume computing structs generated by struct readers.
*
* While there is something to do, we integrate structs in this order
* 1. top element on stack, if stack is not empty
* 2. next element from current struct reader (if empty, use next struct reader)
*
* If struct causally depends on another struct (ref.missing), we put next reader of
* `ref.id.client` on top of stack.
*
* At some point we find a struct that has no causal dependencies,
* then we start emptying the stack.
*
* It is not possible to have circles: i.e. struct1 (from client1) depends on struct2 (from client2)
* depends on struct3 (from client1). Therefore the max stack size is equal to `structReaders.length`.
*
* This method is implemented in a way so that we can resume computation if this update
* causally depends on another update.
*
* @param {Transaction} transaction
* @param {StructStore} store
* @param {Map<number, { i: number, refs: (GC | Item)[] }>} clientsStructRefs
* @return { null | { update: Uint8Array, missing: Map<number,number> } }
*
* @private
* @function
*/
const integrateStructs = (transaction, store, clientsStructRefs) => {
/**
* @type {Array<Item | GC>}
*/
const stack = []
// sort them so that we take the higher id first, in case of conflicts the lower id will probably not conflict with the id from the higher user.
let clientsStructRefsIds = array.from(clientsStructRefs.keys()).sort((a, b) => a - b)
if (clientsStructRefsIds.length === 0) {
return null
}
const getNextStructTarget = () => {
if (clientsStructRefsIds.length === 0) {
return null
}
let nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
while (nextStructsTarget.refs.length === nextStructsTarget.i) {
clientsStructRefsIds.pop()
if (clientsStructRefsIds.length > 0) {
nextStructsTarget = /** @type {{i:number,refs:Array<GC|Item>}} */ (clientsStructRefs.get(clientsStructRefsIds[clientsStructRefsIds.length - 1]))
} else {
return null
}
}
return nextStructsTarget
}
let curStructsTarget = getNextStructTarget()
if (curStructsTarget === null) {
return null
}
/**
* @type {StructStore}
*/
const restStructs = new StructStore()
const missingSV = new Map()
/**
* @param {number} client
* @param {number} clock
*/
const updateMissingSv = (client, clock) => {
const mclock = missingSV.get(client)
if (mclock == null || mclock > clock) {
missingSV.set(client, clock)
}
}
/**
* @type {GC|Item}
*/
let stackHead = /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
// caching the state because it is used very often
const state = new Map()
const addStackToRestSS = () => {
for (const item of stack) {
const client = item.id.client
const inapplicableItems = clientsStructRefs.get(client)
if (inapplicableItems) {
// decrement because we weren't able to apply previous operation
inapplicableItems.i--
restStructs.clients.set(client, inapplicableItems.refs.slice(inapplicableItems.i))
clientsStructRefs.delete(client)
inapplicableItems.i = 0
inapplicableItems.refs = []
} else {
// item was the last item on clientsStructRefs and the field was already cleared. Add item to restStructs and continue
restStructs.clients.set(client, [item])
}
// remove client from clientsStructRefsIds to prevent users from applying the same update again
clientsStructRefsIds = clientsStructRefsIds.filter(c => c !== client)
}
stack.length = 0
}
// iterate over all struct readers until we are done
while (true) {
if (stackHead.constructor !== Skip) {
const localClock = map.setIfUndefined(state, stackHead.id.client, () => getState(store, stackHead.id.client))
const offset = localClock - stackHead.id.clock
if (offset < 0) {
// update from the same client is missing
stack.push(stackHead)
updateMissingSv(stackHead.id.client, stackHead.id.clock - 1)
// hid a dead wall, add all items from stack to restSS
addStackToRestSS()
} else {
const missing = stackHead.getMissing(transaction, store)
if (missing !== null) {
stack.push(stackHead)
// get the struct reader that has the missing struct
/**
* @type {{ refs: Array<GC|Item>, i: number }}
*/
const structRefs = clientsStructRefs.get(/** @type {number} */ (missing)) || { refs: [], i: 0 }
if (structRefs.refs.length === structRefs.i) {
// This update message causally depends on another update message that doesn't exist yet
updateMissingSv(/** @type {number} */ (missing), getState(store, missing))
addStackToRestSS()
} else {
stackHead = structRefs.refs[structRefs.i++]
continue
}
} else if (offset === 0 || offset < stackHead.length) {
// all fine, apply the stackhead
stackHead.integrate(transaction, offset)
state.set(stackHead.id.client, stackHead.id.clock + stackHead.length)
}
}
}
// iterate to next stackHead
if (stack.length > 0) {
stackHead = /** @type {GC|Item} */ (stack.pop())
} else if (curStructsTarget !== null && curStructsTarget.i < curStructsTarget.refs.length) {
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
} else {
curStructsTarget = getNextStructTarget()
if (curStructsTarget === null) {
// we are done!
break
} else {
stackHead = /** @type {GC|Item} */ (curStructsTarget.refs[curStructsTarget.i++])
}
}
}
if (restStructs.clients.size > 0) {
const encoder = new UpdateEncoderV2()
writeClientsStructs(encoder, restStructs, new Map())
// write empty deleteset
// writeDeleteSet(encoder, new DeleteSet())
encoding.writeVarUint(encoder.restEncoder, 0) // => no need for an extra function call, just write 0 deletes
return { missing: missingSV, update: encoder.toUint8Array() }
}
return null
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
*
* @private
* @function
*/
export const writeStructsFromTransaction = (encoder, transaction) => writeClientsStructs(encoder, transaction.doc.store, transaction.beforeState)
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts a decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* @param {UpdateDecoderV1 | UpdateDecoderV2} [structDecoder]
*
* @function
*/
export const readUpdateV2 = (decoder, ydoc, transactionOrigin, structDecoder = new UpdateDecoderV2(decoder)) =>
transact(ydoc, transaction => {
// force that transaction.local is set to non-local
transaction.local = false
let retry = false
const doc = transaction.doc
const store = doc.store
// let start = performance.now()
const ss = readClientsStructRefs(structDecoder, doc)
// console.log('time to read structs: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to merge: ', performance.now() - start) // @todo remove
// start = performance.now()
const restStructs = integrateStructs(transaction, store, ss)
const pending = store.pendingStructs
if (pending) {
// check if we can apply something
for (const [client, clock] of pending.missing) {
if (clock < getState(store, client)) {
retry = true
break
}
}
if (restStructs) {
// merge restStructs into store.pending
for (const [client, clock] of restStructs.missing) {
const mclock = pending.missing.get(client)
if (mclock == null || mclock > clock) {
pending.missing.set(client, clock)
}
}
pending.update = mergeUpdatesV2([pending.update, restStructs.update])
}
} else {
store.pendingStructs = restStructs
}
// console.log('time to integrate: ', performance.now() - start) // @todo remove
// start = performance.now()
const dsRest = readAndApplyDeleteSet(structDecoder, transaction, store)
if (store.pendingDs) {
// @todo we could make a lower-bound state-vector check as we do above
const pendingDSUpdate = new UpdateDecoderV2(decoding.createDecoder(store.pendingDs))
decoding.readVarUint(pendingDSUpdate.restDecoder) // read 0 structs, because we only encode deletes in pendingdsupdate
const dsRest2 = readAndApplyDeleteSet(pendingDSUpdate, transaction, store)
if (dsRest && dsRest2) {
// case 1: ds1 != null && ds2 != null
store.pendingDs = mergeUpdatesV2([dsRest, dsRest2])
} else {
// case 2: ds1 != null
// case 3: ds2 != null
// case 4: ds1 == null && ds2 == null
store.pendingDs = dsRest || dsRest2
}
} else {
// Either dsRest == null && pendingDs == null OR dsRest != null
store.pendingDs = dsRest
}
// console.log('time to cleanup: ', performance.now() - start) // @todo remove
// start = performance.now()
// console.log('time to resume delete readers: ', performance.now() - start) // @todo remove
// start = performance.now()
if (retry) {
const update = /** @type {{update: Uint8Array}} */ (store.pendingStructs).update
store.pendingStructs = null
applyUpdateV2(transaction.doc, update)
}
}, transactionOrigin, false)
/**
* Read and apply a document update.
*
* This function has the same effect as `applyUpdate` but accepts a decoder.
*
* @param {decoding.Decoder} decoder
* @param {Doc} ydoc
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const readUpdate = (decoder, ydoc, transactionOrigin) => readUpdateV2(decoder, ydoc, transactionOrigin, new UpdateDecoderV1(decoder))
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
*
* @param {Doc} ydoc
* @param {Uint8Array} update
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
*
* @function
*/
export const applyUpdateV2 = (ydoc, update, transactionOrigin, YDecoder = UpdateDecoderV2) => {
const decoder = decoding.createDecoder(update)
readUpdateV2(decoder, ydoc, transactionOrigin, new YDecoder(decoder))
}
/**
* Apply a document update created by, for example, `y.on('update', update => ..)` or `update = encodeStateAsUpdate()`.
*
* This function has the same effect as `readUpdate` but accepts an Uint8Array instead of a Decoder.
*
* @param {Doc} ydoc
* @param {Uint8Array} update
* @param {any} [transactionOrigin] This will be stored on `transaction.origin` and `.on('update', (update, origin))`
*
* @function
*/
export const applyUpdate = (ydoc, update, transactionOrigin) => applyUpdateV2(ydoc, update, transactionOrigin, UpdateDecoderV1)
/**
* Write all the document as a single update message. If you specify the state of the remote client (`targetStateVector`) it will
* only write the operations that are missing.
*
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Doc} doc
* @param {Map<number,number>} [targetStateVector] The state of the target that receives the update. Leave empty to write all known structs
*
* @function
*/
export const writeStateAsUpdate = (encoder, doc, targetStateVector = new Map()) => {
writeClientsStructs(encoder, doc.store, targetStateVector)
writeDeleteSet(encoder, createDeleteSetFromStructStore(doc.store))
}
/**
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
* only write the operations that are missing.
*
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @param {UpdateEncoderV1 | UpdateEncoderV2} [encoder]
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => {
const targetStateVector = decodeStateVector(encodedTargetStateVector)
writeStateAsUpdate(encoder, doc, targetStateVector)
const updates = [encoder.toUint8Array()]
// also add the pending updates (if there are any)
if (doc.store.pendingDs) {
updates.push(doc.store.pendingDs)
}
if (doc.store.pendingStructs) {
updates.push(diffUpdateV2(doc.store.pendingStructs.update, encodedTargetStateVector))
}
if (updates.length > 1) {
if (encoder.constructor === UpdateEncoderV1) {
return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update)))
} else if (encoder.constructor === UpdateEncoderV2) {
return mergeUpdatesV2(updates)
}
}
return updates[0]
}
/**
* Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will
* only write the operations that are missing.
*
* Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder
*
* @param {Doc} doc
* @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs
* @return {Uint8Array}
*
* @function
*/
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
/**
* Read state vector from Decoder and return as Map
*
* @param {DSDecoderV1 | DSDecoderV2} decoder
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const readStateVector = decoder => {
const ss = new Map()
const ssLength = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < ssLength; i++) {
const client = decoding.readVarUint(decoder.restDecoder)
const clock = decoding.readVarUint(decoder.restDecoder)
ss.set(client, clock)
}
return ss
}
/**
* Read decodedState and return State as Map.
*
* @param {Uint8Array} decodedState
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
// export const decodeStateVectorV2 = decodedState => readStateVector(new DSDecoderV2(decoding.createDecoder(decodedState)))
/**
* Read decodedState and return State as Map.
*
* @param {Uint8Array} decodedState
* @return {Map<number,number>} Maps `client` to the number next expected `clock` from that client.
*
* @function
*/
export const decodeStateVector = decodedState => readStateVector(new DSDecoderV1(decoding.createDecoder(decodedState)))
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {Map<number,number>} sv
* @function
*/
export const writeStateVector = (encoder, sv) => {
encoding.writeVarUint(encoder.restEncoder, sv.size)
array.from(sv.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, clock]) => {
encoding.writeVarUint(encoder.restEncoder, client) // @todo use a special client decoder that is based on mapping
encoding.writeVarUint(encoder.restEncoder, clock)
})
return encoder
}
/**
* @param {DSEncoderV1 | DSEncoderV2} encoder
* @param {Doc} doc
*
* @function
*/
export const writeDocumentStateVector = (encoder, doc) => writeStateVector(encoder, getStateVector(doc.store))
/**
* Encode State as Uint8Array.
*
* @param {Doc|Map<number,number>} doc
* @param {DSEncoderV1 | DSEncoderV2} [encoder]
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVectorV2 = (doc, encoder = new DSEncoderV2()) => {
if (doc instanceof Map) {
writeStateVector(encoder, doc)
} else {
writeDocumentStateVector(encoder, doc)
}
return encoder.toUint8Array()
}
/**
* Encode State as Uint8Array.
*
* @param {Doc|Map<number,number>} doc
* @return {Uint8Array}
*
* @function
*/
export const encodeStateVector = doc => encodeStateVectorV2(doc, new DSEncoderV1())

View File

@ -1,21 +0,0 @@
import { AbstractType, Item } from '../internals.js' // eslint-disable-line
/**
* Check if `parent` is a parent of `child`.
*
* @param {AbstractType<any>} parent
* @param {Item|null} child
* @return {Boolean} Whether `parent` is a parent of `child`.
*
* @private
* @function
*/
export const isParentOf = (parent, child) => {
while (child !== null) {
if (child.parent === parent) {
return true
}
child = /** @type {AbstractType<any>} */ (child.parent)._item
}
return false
}

View File

@ -1,21 +0,0 @@
import {
AbstractType // eslint-disable-line
} from '../internals.js'
/**
* Convenient helper to log type information.
*
* Do not use in productive systems as the output can be immense!
*
* @param {AbstractType<any>} type
*/
export const logType = type => {
const res = []
let n = type._start
while (n) {
res.push(n)
n = n.right
}
console.log('Children: ', res)
console.log('Children content: ', res.filter(m => !m.deleted).map(m => m.content))
}

View File

@ -1,722 +0,0 @@
import * as binary from 'lib0/binary'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import * as error from 'lib0/error'
import * as f from 'lib0/function'
import * as logging from 'lib0/logging'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as string from 'lib0/string'
import {
ContentAny,
ContentBinary,
ContentDeleted,
ContentDoc,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentString,
ContentType,
createID,
decodeStateVector,
DSEncoderV1,
DSEncoderV2,
GC,
Item,
mergeDeleteSets,
readDeleteSet,
readItemContent,
Skip,
UpdateDecoderV1,
UpdateDecoderV2,
UpdateEncoderV1,
UpdateEncoderV2,
writeDeleteSet,
YXmlElement,
YXmlHook
} from '../internals.js'
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
*/
function * lazyStructReaderGenerator (decoder) {
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfStructs = decoding.readVarUint(decoder.restDecoder)
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numberOfStructs; i++) {
const info = decoder.readInfo()
// @todo use switch instead of ifs
if (info === 10) {
const len = decoding.readVarUint(decoder.restDecoder)
yield new Skip(createID(client, clock), len)
clock += len
} else if ((binary.BITS5 & info) !== 0) {
const cantCopyParentInfo = (info & (binary.BIT7 | binary.BIT8)) === 0
// If parent = null and neither left nor right are defined, then we know that `parent` is child of `y`
// and we read the next string as parentYKey.
// It indicates how we store/retrieve parent from `y.share`
// @type {string|null}
const struct = new Item(
createID(client, clock),
null, // left
(info & binary.BIT8) === binary.BIT8 ? decoder.readLeftID() : null, // origin
null, // right
(info & binary.BIT7) === binary.BIT7 ? decoder.readRightID() : null, // right origin
// @ts-ignore Force writing a string here.
cantCopyParentInfo ? (decoder.readParentInfo() ? decoder.readString() : decoder.readLeftID()) : null, // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
yield struct
clock += struct.length
} else {
const len = decoder.readLen()
yield new GC(createID(client, clock), len)
clock += len
}
}
}
}
export class LazyStructReader {
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder
* @param {boolean} filterSkips
*/
constructor (decoder, filterSkips) {
this.gen = lazyStructReaderGenerator(decoder)
/**
* @type {null | Item | Skip | GC}
*/
this.curr = null
this.done = false
this.filterSkips = filterSkips
this.next()
}
/**
* @return {Item | GC | Skip |null}
*/
next () {
// ignore "Skip" structs
do {
this.curr = this.gen.next().value || null
} while (this.filterSkips && this.curr !== null && this.curr.constructor === Skip)
return this.curr
}
}
/**
* @param {Uint8Array} update
*
*/
export const logUpdate = update => logUpdateV2(update, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*
*/
export const logUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
}
logging.print('Structs: ', structs)
const ds = readDeleteSet(updateDecoder)
logging.print('DeleteSet: ', ds)
}
/**
* @param {Uint8Array} update
*
*/
export const decodeUpdate = (update) => decodeUpdateV2(update, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
*
*/
export const decodeUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const structs = []
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
structs.push(curr)
}
return {
structs,
ds: readDeleteSet(updateDecoder)
}
}
export class LazyStructWriter {
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
*/
constructor (encoder) {
this.currClient = 0
this.startClock = 0
this.written = 0
this.encoder = encoder
/**
* We want to write operations lazily, but also we need to know beforehand how many operations we want to write for each client.
*
* This kind of meta-information (#clients, #structs-per-client-written) is written to the restEncoder.
*
* We fragment the restEncoder and store a slice of it per-client until we know how many clients there are.
* When we flush (toUint8Array) we write the restEncoder using the fragments and the meta-information.
*
* @type {Array<{ written: number, restEncoder: Uint8Array }>}
*/
this.clientStructs = []
}
}
/**
* @param {Array<Uint8Array>} updates
* @return {Uint8Array}
*/
export const mergeUpdates = updates => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {Uint8Array} update
* @param {typeof DSEncoderV1 | typeof DSEncoderV2} YEncoder
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
* @return {Uint8Array}
*/
export const encodeStateVectorFromUpdateV2 = (update, YEncoder = DSEncoderV2, YDecoder = UpdateDecoderV2) => {
const encoder = new YEncoder()
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
let curr = updateDecoder.curr
if (curr !== null) {
let size = 0
let currClient = curr.id.client
let stopCounting = curr.id.clock !== 0 // must start at 0
let currClock = stopCounting ? 0 : curr.id.clock + curr.length
for (; curr !== null; curr = updateDecoder.next()) {
if (currClient !== curr.id.client) {
if (currClock !== 0) {
size++
// We found a new client
// write what we have to the encoder
encoding.writeVarUint(encoder.restEncoder, currClient)
encoding.writeVarUint(encoder.restEncoder, currClock)
}
currClient = curr.id.client
currClock = 0
stopCounting = curr.id.clock !== 0
}
// we ignore skips
if (curr.constructor === Skip) {
stopCounting = true
}
if (!stopCounting) {
currClock = curr.id.clock + curr.length
}
}
// write what we have
if (currClock !== 0) {
size++
encoding.writeVarUint(encoder.restEncoder, currClient)
encoding.writeVarUint(encoder.restEncoder, currClock)
}
// prepend the size of the state vector
const enc = encoding.createEncoder()
encoding.writeVarUint(enc, size)
encoding.writeBinaryEncoder(enc, encoder.restEncoder)
encoder.restEncoder = enc
return encoder.toUint8Array()
} else {
encoding.writeVarUint(encoder.restEncoder, 0)
return encoder.toUint8Array()
}
}
/**
* @param {Uint8Array} update
* @return {Uint8Array}
*/
export const encodeStateVectorFromUpdate = update => encodeStateVectorFromUpdateV2(update, DSEncoderV1, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
* @return {{ from: Map<number,number>, to: Map<number,number> }}
*/
export const parseUpdateMetaV2 = (update, YDecoder = UpdateDecoderV2) => {
/**
* @type {Map<number, number>}
*/
const from = new Map()
/**
* @type {Map<number, number>}
*/
const to = new Map()
const updateDecoder = new LazyStructReader(new YDecoder(decoding.createDecoder(update)), false)
let curr = updateDecoder.curr
if (curr !== null) {
let currClient = curr.id.client
let currClock = curr.id.clock
// write the beginning to `from`
from.set(currClient, currClock)
for (; curr !== null; curr = updateDecoder.next()) {
if (currClient !== curr.id.client) {
// We found a new client
// write the end to `to`
to.set(currClient, currClock)
// write the beginning to `from`
from.set(curr.id.client, curr.id.clock)
// update currClient
currClient = curr.id.client
}
currClock = curr.id.clock + curr.length
}
// write the end to `to`
to.set(currClient, currClock)
}
return { from, to }
}
/**
* @param {Uint8Array} update
* @return {{ from: Map<number,number>, to: Map<number,number> }}
*/
export const parseUpdateMeta = update => parseUpdateMetaV2(update, UpdateDecoderV1)
/**
* This method is intended to slice any kind of struct and retrieve the right part.
* It does not handle side-effects, so it should only be used by the lazy-encoder.
*
* @param {Item | GC | Skip} left
* @param {number} diff
* @return {Item | GC}
*/
const sliceStruct = (left, diff) => {
if (left.constructor === GC) {
const { client, clock } = left.id
return new GC(createID(client, clock + diff), left.length - diff)
} else if (left.constructor === Skip) {
const { client, clock } = left.id
return new Skip(createID(client, clock + diff), left.length - diff)
} else {
const leftItem = /** @type {Item} */ (left)
const { client, clock } = leftItem.id
return new Item(
createID(client, clock + diff),
null,
createID(client, clock + diff - 1),
null,
leftItem.rightOrigin,
leftItem.parent,
leftItem.parentSub,
leftItem.content.splice(diff)
)
}
}
/**
*
* This function works similarly to `readUpdateV2`.
*
* @param {Array<Uint8Array>} updates
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
* @return {Uint8Array}
*/
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
if (updates.length === 1) {
return updates[0]
}
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
let lazyStructDecoders = updateDecoders.map(decoder => new LazyStructReader(decoder, true))
/**
* @todo we don't need offset because we always slice before
* @type {null | { struct: Item | GC | Skip, offset: number }}
*/
let currWrite = null
const updateEncoder = new YEncoder()
// write structs lazily
const lazyStructEncoder = new LazyStructWriter(updateEncoder)
// Note: We need to ensure that all lazyStructDecoders are fully consumed
// Note: Should merge document updates whenever possible - even from different updates
// Note: Should handle that some operations cannot be applied yet ()
while (true) {
// Write higher clients first ⇒ sort by clientID & clock and remove decoders without content
lazyStructDecoders = lazyStructDecoders.filter(dec => dec.curr !== null)
lazyStructDecoders.sort(
/** @type {function(any,any):number} */ (dec1, dec2) => {
if (dec1.curr.id.client === dec2.curr.id.client) {
const clockDiff = dec1.curr.id.clock - dec2.curr.id.clock
if (clockDiff === 0) {
// @todo remove references to skip since the structDecoders must filter Skips.
return dec1.curr.constructor === dec2.curr.constructor
? 0
: dec1.curr.constructor === Skip ? 1 : -1 // we are filtering skips anyway.
} else {
return clockDiff
}
} else {
return dec2.curr.id.client - dec1.curr.id.client
}
}
)
if (lazyStructDecoders.length === 0) {
break
}
const currDecoder = lazyStructDecoders[0]
// write from currDecoder until the next operation is from another client or if filler-struct
// then we need to reorder the decoders and find the next operation to write
const firstClient = /** @type {Item | GC} */ (currDecoder.curr).id.client
if (currWrite !== null) {
let curr = /** @type {Item | GC | null} */ (currDecoder.curr)
let iterated = false
// iterate until we find something that we haven't written already
// remember: first the high client-ids are written
while (curr !== null && curr.id.clock + curr.length <= currWrite.struct.id.clock + currWrite.struct.length && curr.id.client >= currWrite.struct.id.client) {
curr = currDecoder.next()
iterated = true
}
if (
curr === null || // current decoder is empty
curr.id.client !== firstClient || // check whether there is another decoder that has has updates from `firstClient`
(iterated && curr.id.clock > currWrite.struct.id.clock + currWrite.struct.length) // the above while loop was used and we are potentially missing updates
) {
continue
}
if (firstClient !== currWrite.struct.id.client) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: curr, offset: 0 }
currDecoder.next()
} else {
if (currWrite.struct.id.clock + currWrite.struct.length < curr.id.clock) {
// @todo write currStruct & set currStruct = Skip(clock = currStruct.id.clock + currStruct.length, length = curr.id.clock - self.clock)
if (currWrite.struct.constructor === Skip) {
// extend existing skip
currWrite.struct.length = curr.id.clock + curr.length - currWrite.struct.id.clock
} else {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
const diff = curr.id.clock - currWrite.struct.id.clock - currWrite.struct.length
/**
* @type {Skip}
*/
const struct = new Skip(createID(firstClient, currWrite.struct.id.clock + currWrite.struct.length), diff)
currWrite = { struct, offset: 0 }
}
} else { // if (currWrite.struct.id.clock + currWrite.struct.length >= curr.id.clock) {
const diff = currWrite.struct.id.clock + currWrite.struct.length - curr.id.clock
if (diff > 0) {
if (currWrite.struct.constructor === Skip) {
// prefer to slice Skip because the other struct might contain more information
currWrite.struct.length -= diff
} else {
curr = sliceStruct(curr, diff)
}
}
if (!currWrite.struct.mergeWith(/** @type {any} */ (curr))) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: curr, offset: 0 }
currDecoder.next()
}
}
}
} else {
currWrite = { struct: /** @type {Item | GC} */ (currDecoder.curr), offset: 0 }
currDecoder.next()
}
for (
let next = currDecoder.curr;
next !== null && next.id.client === firstClient && next.id.clock === currWrite.struct.id.clock + currWrite.struct.length && next.constructor !== Skip;
next = currDecoder.next()
) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = { struct: next, offset: 0 }
}
}
if (currWrite !== null) {
writeStructToLazyStructWriter(lazyStructEncoder, currWrite.struct, currWrite.offset)
currWrite = null
}
finishLazyStructWriting(lazyStructEncoder)
const dss = updateDecoders.map(decoder => readDeleteSet(decoder))
const ds = mergeDeleteSets(dss)
writeDeleteSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
*/
export const diffUpdateV2 = (update, sv, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
const state = decodeStateVector(sv)
const encoder = new YEncoder()
const lazyStructWriter = new LazyStructWriter(encoder)
const decoder = new YDecoder(decoding.createDecoder(update))
const reader = new LazyStructReader(decoder, false)
while (reader.curr) {
const curr = reader.curr
const currClient = curr.id.client
const svClock = state.get(currClient) || 0
if (reader.curr.constructor === Skip) {
// the first written struct shouldn't be a skip
reader.next()
continue
}
if (curr.id.clock + curr.length > svClock) {
writeStructToLazyStructWriter(lazyStructWriter, curr, math.max(svClock - curr.id.clock, 0))
reader.next()
while (reader.curr && reader.curr.id.client === currClient) {
writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0)
reader.next()
}
} else {
// read until something new comes up
while (reader.curr && reader.curr.id.client === currClient && reader.curr.id.clock + reader.curr.length <= svClock) {
reader.next()
}
}
}
finishLazyStructWriting(lazyStructWriter)
// write ds
const ds = readDeleteSet(decoder)
writeDeleteSet(encoder, ds)
return encoder.toUint8Array()
}
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
*/
export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {LazyStructWriter} lazyWriter
*/
const flushLazyStructWriter = lazyWriter => {
if (lazyWriter.written > 0) {
lazyWriter.clientStructs.push({ written: lazyWriter.written, restEncoder: encoding.toUint8Array(lazyWriter.encoder.restEncoder) })
lazyWriter.encoder.restEncoder = encoding.createEncoder()
lazyWriter.written = 0
}
}
/**
* @param {LazyStructWriter} lazyWriter
* @param {Item | GC} struct
* @param {number} offset
*/
const writeStructToLazyStructWriter = (lazyWriter, struct, offset) => {
// flush curr if we start another client
if (lazyWriter.written > 0 && lazyWriter.currClient !== struct.id.client) {
flushLazyStructWriter(lazyWriter)
}
if (lazyWriter.written === 0) {
lazyWriter.currClient = struct.id.client
// write next client
lazyWriter.encoder.writeClient(struct.id.client)
// write startClock
encoding.writeVarUint(lazyWriter.encoder.restEncoder, struct.id.clock + offset)
}
struct.write(lazyWriter.encoder, offset)
lazyWriter.written++
}
/**
* Call this function when we collected all parts and want to
* put all the parts together. After calling this method,
* you can continue using the UpdateEncoder.
*
* @param {LazyStructWriter} lazyWriter
*/
const finishLazyStructWriting = (lazyWriter) => {
flushLazyStructWriter(lazyWriter)
// this is a fresh encoder because we called flushCurr
const restEncoder = lazyWriter.encoder.restEncoder
/**
* Now we put all the fragments together.
* This works similarly to `writeClientsStructs`
*/
// write # states that were updated - i.e. the clients
encoding.writeVarUint(restEncoder, lazyWriter.clientStructs.length)
for (let i = 0; i < lazyWriter.clientStructs.length; i++) {
const partStructs = lazyWriter.clientStructs[i]
/**
* Works similarly to `writeStructs`
*/
// write # encoded structs
encoding.writeVarUint(restEncoder, partStructs.written)
// write the rest of the fragment
encoding.writeUint8Array(restEncoder, partStructs.restEncoder)
}
}
/**
* @param {Uint8Array} update
* @param {function(Item|GC|Skip):Item|GC|Skip} blockTransformer
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} YDecoder
* @param {typeof UpdateEncoderV2 | typeof UpdateEncoderV1 } YEncoder
*/
export const convertUpdateFormat = (update, blockTransformer, YDecoder, YEncoder) => {
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, false)
const updateEncoder = new YEncoder()
const lazyWriter = new LazyStructWriter(updateEncoder)
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
writeStructToLazyStructWriter(lazyWriter, blockTransformer(curr), 0)
}
finishLazyStructWriting(lazyWriter)
const ds = readDeleteSet(updateDecoder)
writeDeleteSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @typedef {Object} ObfuscatorOptions
* @property {boolean} [ObfuscatorOptions.formatting=true]
* @property {boolean} [ObfuscatorOptions.subdocs=true]
* @property {boolean} [ObfuscatorOptions.yxml=true] Whether to obfuscate nodeName / hookName
*/
/**
* @param {ObfuscatorOptions} obfuscator
*/
const createObfuscator = ({ formatting = true, subdocs = true, yxml = true } = {}) => {
let i = 0
const mapKeyCache = map.create()
const nodeNameCache = map.create()
const formattingKeyCache = map.create()
const formattingValueCache = map.create()
formattingValueCache.set(null, null) // end of a formatting range should always be the end of a formatting range
/**
* @param {Item|GC|Skip} block
* @return {Item|GC|Skip}
*/
return block => {
switch (block.constructor) {
case GC:
case Skip:
return block
case Item: {
const item = /** @type {Item} */ (block)
const content = item.content
switch (content.constructor) {
case ContentDeleted:
break
case ContentType: {
if (yxml) {
const type = /** @type {ContentType} */ (content).type
if (type instanceof YXmlElement) {
type.nodeName = map.setIfUndefined(nodeNameCache, type.nodeName, () => 'node-' + i)
}
if (type instanceof YXmlHook) {
type.hookName = map.setIfUndefined(nodeNameCache, type.hookName, () => 'hook-' + i)
}
}
break
}
case ContentAny: {
const c = /** @type {ContentAny} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentBinary: {
const c = /** @type {ContentBinary} */ (content)
c.content = new Uint8Array([i])
break
}
case ContentDoc: {
const c = /** @type {ContentDoc} */ (content)
if (subdocs) {
c.opts = {}
c.doc.guid = i + ''
}
break
}
case ContentEmbed: {
const c = /** @type {ContentEmbed} */ (content)
c.embed = {}
break
}
case ContentFormat: {
const c = /** @type {ContentFormat} */ (content)
if (formatting) {
c.key = map.setIfUndefined(formattingKeyCache, c.key, () => i + '')
c.value = map.setIfUndefined(formattingValueCache, c.value, () => ({ i }))
}
break
}
case ContentJSON: {
const c = /** @type {ContentJSON} */ (content)
c.arr = c.arr.map(() => i)
break
}
case ContentString: {
const c = /** @type {ContentString} */ (content)
c.str = string.repeat((i % 10) + '', c.str.length)
break
}
default:
// unknown content type
error.unexpectedCase()
}
if (item.parentSub) {
item.parentSub = map.setIfUndefined(mapKeyCache, item.parentSub, () => i + '')
}
i++
return block
}
default:
// unknown block-type
error.unexpectedCase()
}
}
}
/**
* This function obfuscates the content of a Yjs update. This is useful to share
* buggy Yjs documents while significantly limiting the possibility that a
* developer can on the user. Note that it might still be possible to deduce
* some information by analyzing the "structure" of the document or by analyzing
* the typing behavior using the CRDT-related metadata that is still kept fully
* intact.
*
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdate = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV1, UpdateEncoderV1)
/**
* @param {Uint8Array} update
* @param {ObfuscatorOptions} [opts]
*/
export const obfuscateUpdateV2 = (update, opts) => convertUpdateFormat(update, createObfuscator(opts), UpdateDecoderV2, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV1ToV2 = update => convertUpdateFormat(update, f.id, UpdateDecoderV1, UpdateEncoderV2)
/**
* @param {Uint8Array} update
*/
export const convertUpdateFormatV2ToV1 = update => convertUpdateFormat(update, f.id, UpdateDecoderV2, UpdateEncoderV1)

237
src/y.js Normal file
View File

@ -0,0 +1,237 @@
/* @flow */
'use strict'
require('./Connector.js')(Y)
require('./Database.js')(Y)
require('./Transaction.js')(Y)
require('./Struct.js')(Y)
require('./Utils.js')(Y)
require('./Connectors/Test.js')(Y)
Y.debug = require('debug')
var requiringModules = {}
module.exports = Y
Y.requiringModules = requiringModules
Y.extend = function (name, value) {
if (arguments.length === 2 && typeof name === 'string') {
if (value instanceof Y.utils.CustomTypeDefinition) {
Y[name] = value.parseArguments
} else {
Y[name] = value
}
if (requiringModules[name] != null) {
requiringModules[name].resolve()
delete requiringModules[name]
}
} else {
for (var i = 0; i < arguments.length; i++) {
var f = arguments[i]
if (typeof f === 'function') {
f(Y)
} else {
throw new Error('Expected function!')
}
}
}
}
Y.requestModules = requestModules
function requestModules (modules) {
var sourceDir
if (Y.sourceDir === null) {
sourceDir = null
} else {
sourceDir = Y.sourceDir || '/bower_components'
}
// determine if this module was compiled for es5 or es6 (y.js vs. y.es6)
// if Insert.execute is a Function, then it isnt a generator..
// then load the es5(.js) files..
var extention = typeof regeneratorRuntime !== 'undefined' ? '.js' : '.es6'
var promises = []
for (var i = 0; i < modules.length; i++) {
var module = modules[i].split('(')[0]
var modulename = 'y-' + module.toLowerCase()
if (Y[module] == null) {
if (requiringModules[module] == null) {
// module does not exist
if (typeof window !== 'undefined' && window.Y !== 'undefined') {
if (sourceDir != null) {
var imported = document.createElement('script')
imported.src = sourceDir + '/' + modulename + '/' + modulename + extention
document.head.appendChild(imported)
}
let requireModule = {}
requiringModules[module] = requireModule
requireModule.promise = new Promise(function (resolve) {
requireModule.resolve = resolve
})
promises.push(requireModule.promise)
} else {
console.info('YJS: Please do not depend on automatic requiring of modules anymore! Extend modules as follows `require(\'y-modulename\')(Y)`')
require(modulename)(Y)
}
} else {
promises.push(requiringModules[modules[i]].promise)
}
}
}
return Promise.all(promises)
}
/* ::
type MemoryOptions = {
name: 'memory'
}
type IndexedDBOptions = {
name: 'indexeddb',
namespace: string
}
type DbOptions = MemoryOptions | IndexedDBOptions
type WebRTCOptions = {
name: 'webrtc',
room: string
}
type WebsocketsClientOptions = {
name: 'websockets-client',
room: string
}
type ConnectionOptions = WebRTCOptions | WebsocketsClientOptions
type YOptions = {
connector: ConnectionOptions,
db: DbOptions,
types: Array<TypeName>,
sourceDir: string,
share: {[key: string]: TypeName}
}
*/
function Y (opts/* :YOptions */) /* :Promise<YConfig> */ {
if (opts.hasOwnProperty('sourceDir')) {
Y.sourceDir = opts.sourceDir
}
opts.types = opts.types != null ? opts.types : []
var modules = [opts.db.name, opts.connector.name].concat(opts.types)
for (var name in opts.share) {
modules.push(opts.share[name])
}
return new Promise(function (resolve, reject) {
if (opts == null) reject('An options object is expected! ')
else if (opts.connector == null) reject('You must specify a connector! (missing connector property)')
else if (opts.connector.name == null) reject('You must specify connector name! (missing connector.name property)')
else if (opts.db == null) reject('You must specify a database! (missing db property)')
else if (opts.connector.name == null) reject('You must specify db name! (missing db.name property)')
else {
opts = Y.utils.copyObject(opts)
opts.connector = Y.utils.copyObject(opts.connector)
opts.db = Y.utils.copyObject(opts.db)
opts.share = Y.utils.copyObject(opts.share)
setTimeout(function () {
Y.requestModules(modules).then(function () {
var yconfig = new YConfig(opts)
yconfig.db.whenUserIdSet(function () {
yconfig.init(function () {
resolve(yconfig)
})
})
}).catch(reject)
}, 0)
}
})
}
class YConfig {
/* ::
db: Y.AbstractDatabase;
connector: Y.AbstractConnector;
share: {[key: string]: any};
options: Object;
*/
constructor (opts, callback) {
this.options = opts
this.db = new Y[opts.db.name](this, opts.db)
this.connector = new Y[opts.connector.name](this, opts.connector)
this.connected = true
}
init (callback) {
var opts = this.options
var share = {}
this.share = share
this.db.requestTransaction(function * requestTransaction () {
// create shared object
for (var propertyname in opts.share) {
var typeConstructor = opts.share[propertyname].split('(')
var typeName = typeConstructor.splice(0, 1)
var type = Y[typeName]
var typedef = type.typeDefinition
var id = ['_', typedef.struct + '_' + typeName + '_' + propertyname + '_' + typeConstructor]
var args = []
if (typeConstructor.length === 1) {
try {
args = JSON.parse('[' + typeConstructor[0].split(')')[0] + ']')
} catch (e) {
throw new Error('Was not able to parse type definition! (share.' + propertyname + ')')
}
if (type.typeDefinition.parseArguments == null) {
throw new Error(typeName + ' does not expect arguments!')
} else {
args = typedef.parseArguments(args[0])[1]
}
}
share[propertyname] = yield* this.store.initType.call(this, id, args)
}
this.store.whenTransactionsFinished()
.then(callback)
})
}
isConnected () {
return this.connector.isSynced
}
disconnect () {
if (this.connected) {
this.connected = false
return this.connector.disconnect()
} else {
return Promise.resolve()
}
}
reconnect () {
if (!this.connected) {
this.connected = true
return this.connector.reconnect()
} else {
return Promise.resolve()
}
}
destroy () {
var self = this
return this.close().then(function () {
if (self.db.deleteDB != null) {
return self.db.deleteDB()
} else {
return Promise.resolve()
}
})
}
close () {
var self = this
this.share = null
if (this.connector.destroy != null) {
this.connector.destroy()
} else {
this.connector.disconnect()
}
return this.db.whenTransactionsFinished(function () {
this.db.destroyTypes()
// make sure to wait for all transactions before destroying the db
this.db.requestTransaction(function * () {
yield* self.db.destroy()
})
return this.db.whenTransactionsFinished()
})
}
}

View File

@ -1,9 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Testing Yjs</title>
</head>
<body>
<script type="module" src="./dist/tests.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,329 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
/**
* @param {t.TestCase} _tc
*/
export const testAfterTransactionRecursion = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment('')
ydoc.on('afterTransaction', tr => {
if (tr.origin === 'test') {
yxml.toJSON()
}
})
ydoc.transact(_tr => {
for (let i = 0; i < 15000; i++) {
yxml.push([new Y.XmlText('a')])
}
}, 'test')
}
/**
* @param {t.TestCase} _tc
*/
export const testOriginInTransaction = _tc => {
const doc = new Y.Doc()
const ytext = doc.getText()
/**
* @type {Array<string>}
*/
const origins = []
doc.on('afterTransaction', (tr) => {
origins.push(tr.origin)
if (origins.length <= 1) {
ytext.toDelta(Y.snapshot(doc)) // adding a snapshot forces toDelta to create a cleanup transaction
doc.transact(() => {
ytext.insert(0, 'a')
}, 'nested')
}
})
doc.transact(() => {
ytext.insert(0, '0')
}, 'first')
t.compareArrays(origins, ['first', 'cleanup', 'nested'])
}
/**
* Client id should be changed when an instance receives updates from another client using the same client id.
*
* @param {t.TestCase} _tc
*/
export const testClientIdDuplicateChange = _tc => {
const doc1 = new Y.Doc()
doc1.clientID = 0
const doc2 = new Y.Doc()
doc2.clientID = 0
t.assert(doc2.clientID === doc1.clientID)
doc1.getArray('a').insert(0, [1, 2])
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.clientID !== doc1.clientID)
}
/**
* @param {t.TestCase} _tc
*/
export const testGetTypeEmptyId = _tc => {
const doc1 = new Y.Doc()
doc1.getText('').insert(0, 'h')
doc1.getText().insert(1, 'i')
const doc2 = new Y.Doc()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.getText().toString() === 'hi')
t.assert(doc2.getText('').toString() === 'hi')
}
/**
* @param {t.TestCase} _tc
*/
export const testToJSON = _tc => {
const doc = new Y.Doc()
t.compare(doc.toJSON(), {}, 'doc.toJSON yields empty object')
const arr = doc.getArray('array')
arr.push(['test1'])
const map = doc.getMap('map')
map.set('k1', 'v1')
const map2 = new Y.Map()
map.set('k2', map2)
map2.set('m2k1', 'm2v1')
t.compare(doc.toJSON(), {
array: ['test1'],
map: {
k1: 'v1',
k2: {
m2k1: 'm2v1'
}
}
}, 'doc.toJSON has array and recursive map')
}
/**
* @param {t.TestCase} _tc
*/
export const testSubdoc = _tc => {
const doc = new Y.Doc()
doc.load() // doesn't do anything
{
/**
* @type {Array<any>|null}
*/
let event = /** @type {any} */ (null)
doc.on('subdocs', subdocs => {
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
})
const subdocs = doc.getMap('mysubdocs')
const docA = new Y.Doc({ guid: 'a' })
docA.load()
subdocs.set('a', docA)
t.compare(event, [['a'], [], ['a']])
event = null
subdocs.get('a').load()
t.assert(event === null)
event = null
subdocs.get('a').destroy()
t.compare(event, [['a'], ['a'], []])
subdocs.get('a').load()
t.compare(event, [[], [], ['a']])
subdocs.set('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
t.compare(event, [['a'], [], []])
subdocs.get('b').load()
t.compare(event, [[], [], ['a']])
const docC = new Y.Doc({ guid: 'c' })
docC.load()
subdocs.set('c', docC)
t.compare(event, [['c'], [], ['c']])
t.compare(Array.from(doc.getSubdocGuids()), ['a', 'c'])
}
const doc2 = new Y.Doc()
{
t.compare(Array.from(doc2.getSubdocs()), [])
/**
* @type {Array<any>|null}
*/
let event = /** @type {any} */ (null)
doc2.on('subdocs', subdocs => {
event = [Array.from(subdocs.added).map(d => d.guid), Array.from(subdocs.removed).map(d => d.guid), Array.from(subdocs.loaded).map(d => d.guid)]
})
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
t.compare(event, [['a', 'a', 'c'], [], []])
doc2.getMap('mysubdocs').get('a').load()
t.compare(event, [[], [], ['a']])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
doc2.getMap('mysubdocs').delete('a')
t.compare(event, [[], ['a'], []])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
}
}
/**
* @param {t.TestCase} _tc
*/
export const testSubdocLoadEdgeCases = _tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc()
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc3.shouldLoad === false)
t.assert(subdoc3.autoLoad === false)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc3))
// load
subdoc3.load()
t.assert(subdoc3.shouldLoad)
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} _tc
*/
export const testSubdocLoadEdgeCasesAutoload = _tc => {
const ydoc = new Y.Doc()
const yarray = ydoc.getArray()
const subdoc1 = new Y.Doc({ autoLoad: true })
/**
* @type {any}
*/
let lastEvent = null
ydoc.on('subdocs', event => {
lastEvent = event
})
yarray.insert(0, [subdoc1])
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc1))
t.assert(lastEvent !== null && lastEvent.added.has(subdoc1))
// destroy and check whether lastEvent adds it again to added (it shouldn't)
subdoc1.destroy()
const subdoc2 = yarray.get(0)
t.assert(subdoc1 !== subdoc2)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && !lastEvent.loaded.has(subdoc2))
// load
subdoc2.load()
t.assert(lastEvent !== null && !lastEvent.added.has(subdoc2))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc2))
// apply from remote
const ydoc2 = new Y.Doc()
ydoc2.on('subdocs', event => {
lastEvent = event
})
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const subdoc3 = ydoc2.getArray().get(0)
t.assert(subdoc1.shouldLoad)
t.assert(subdoc1.autoLoad)
t.assert(lastEvent !== null && lastEvent.added.has(subdoc3))
t.assert(lastEvent !== null && lastEvent.loaded.has(subdoc3))
}
/**
* @param {t.TestCase} _tc
*/
export const testSubdocsUndo = _tc => {
const ydoc = new Y.Doc()
const elems = ydoc.getXmlFragment()
const undoManager = new Y.UndoManager(elems)
const subdoc = new Y.Doc()
// @ts-ignore
elems.insert(0, [subdoc])
undoManager.undo()
undoManager.redo()
t.assert(elems.length === 1)
}
/**
* @param {t.TestCase} _tc
*/
export const testLoadDocsEvent = async _tc => {
const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false)
let loadedEvent = false
ydoc.on('load', () => {
loadedEvent = true
})
ydoc.emit('load', [ydoc])
await ydoc.whenLoaded
t.assert(loadedEvent)
t.assert(ydoc.isLoaded)
}
/**
* @param {t.TestCase} _tc
*/
export const testSyncDocsEvent = async _tc => {
const ydoc = new Y.Doc()
t.assert(ydoc.isLoaded === false)
t.assert(ydoc.isSynced === false)
let loadedEvent = false
ydoc.once('load', () => {
loadedEvent = true
})
let syncedEvent = false
ydoc.once('sync', /** @param {any} isSynced */ (isSynced) => {
syncedEvent = true
t.assert(isSynced)
})
ydoc.emit('sync', [true, ydoc])
await ydoc.whenLoaded
const oldWhenSynced = ydoc.whenSynced
await ydoc.whenSynced
t.assert(loadedEvent)
t.assert(syncedEvent)
t.assert(ydoc.isLoaded)
t.assert(ydoc.isSynced)
let loadedEvent2 = false
ydoc.on('load', () => {
loadedEvent2 = true
})
let syncedEvent2 = false
ydoc.on('sync', (isSynced) => {
syncedEvent2 = true
t.assert(isSynced === false)
})
ydoc.emit('sync', [false, ydoc])
t.assert(!loadedEvent2)
t.assert(syncedEvent2)
t.assert(ydoc.isLoaded)
t.assert(!ydoc.isSynced)
t.assert(ydoc.whenSynced !== oldWhenSynced)
}

View File

@ -1,108 +0,0 @@
import * as t from 'lib0/testing'
import * as promise from 'lib0/promise'
import {
contentRefs,
readContentBinary,
readContentDeleted,
readContentString,
readContentJSON,
readContentEmbed,
readContentType,
readContentFormat,
readContentAny,
readContentDoc,
Doc,
PermanentUserData,
encodeStateAsUpdate,
applyUpdate
} from '../src/internals.js'
import * as Y from '../src/index.js'
/**
* @param {t.TestCase} tc
*/
export const testStructReferences = tc => {
t.assert(contentRefs.length === 11)
t.assert(contentRefs[1] === readContentDeleted)
t.assert(contentRefs[2] === readContentJSON) // TODO: deprecate content json?
t.assert(contentRefs[3] === readContentBinary)
t.assert(contentRefs[4] === readContentString)
t.assert(contentRefs[5] === readContentEmbed)
t.assert(contentRefs[6] === readContentFormat)
t.assert(contentRefs[7] === readContentType)
t.assert(contentRefs[8] === readContentAny)
t.assert(contentRefs[9] === readContentDoc)
// contentRefs[10] is reserved for Skip structs
}
/**
* 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')
}
/**
* Reported here: https://github.com/yjs/yjs/issues/308
* @param {t.TestCase} tc
*/
export const testDiffStateVectorOfUpdateIsEmpty = tc => {
const ydoc = new Y.Doc()
/**
* @type {any}
*/
let sv = null
ydoc.getText().insert(0, 'a')
ydoc.on('update', update => {
sv = Y.encodeStateVectorFromUpdate(update)
})
// should produce an update with an empty state vector (because previous ops are missing)
ydoc.getText().insert(0, 'a')
t.assert(sv !== null && sv.byteLength === 1 && sv[0] === 0)
}
/**
* Reported here: https://github.com/yjs/yjs/issues/308
* @param {t.TestCase} tc
*/
export const testDiffStateVectorOfUpdateIgnoresSkips = tc => {
const ydoc = new Y.Doc()
/**
* @type {Array<Uint8Array>}
*/
const updates = []
ydoc.on('update', update => {
updates.push(update)
})
ydoc.getText().insert(0, 'a')
ydoc.getText().insert(0, 'b')
ydoc.getText().insert(0, 'c')
const update13 = Y.mergeUpdates([updates[0], updates[2]])
const sv = Y.encodeStateVectorFromUpdate(update13)
const state = Y.decodeStateVector(sv)
t.assert(state.get(ydoc.clientID) === 1)
t.assert(state.size === 1)
}

View File

@ -1,42 +0,0 @@
/* eslint-env node */
import * as map from './y-map.tests.js'
import * as array from './y-array.tests.js'
import * as text from './y-text.tests.js'
import * as xml from './y-xml.tests.js'
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 * as updates from './updates.tests.js'
import * as relativePositions from './relativePositions.tests.js'
import { runTests } from 'lib0/testing'
import { isBrowser, isNode } from 'lib0/environment'
import * as log from 'lib0/logging'
import { environment } from 'lib0'
if (isBrowser) {
log.createVConsole(document.body)
}
/**
* @type {any}
*/
const tests = {
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions
}
const run = async () => {
if (environment.isNode) {
// tests.nodejs = await import('./node.tests.js')
}
const success = await runTests(tests)
/* istanbul ignore next */
if (isNode) {
process.exit(success ? 0 : 1)
}
}
run()

View File

@ -1,145 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
/**
* @param {Y.Text} ytext
*/
const checkRelativePositions = ytext => {
// test if all positions are encoded and restored correctly
for (let i = 0; i < ytext.length; i++) {
// for all types of associations..
for (let assoc = -1; assoc < 2; assoc++) {
const rpos = Y.createRelativePositionFromTypeIndex(ytext, i, assoc)
const encodedRpos = Y.encodeRelativePosition(rpos)
const decodedRpos = Y.decodeRelativePosition(encodedRpos)
const absPos = /** @type {Y.AbsolutePosition} */ (Y.createAbsolutePositionFromRelativePosition(decodedRpos, /** @type {Y.Doc} */ (ytext.doc)))
t.assert(absPos.index === i)
t.assert(absPos.assoc === assoc)
}
}
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase1 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '1')
ytext.insert(0, 'abc')
ytext.insert(0, 'z')
ytext.insert(0, 'y')
ytext.insert(0, 'x')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase2 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'abc')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase3 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'abc')
ytext.insert(0, '1')
ytext.insert(0, 'xyz')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase4 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '1')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase5 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '2')
ytext.insert(0, '1')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionCase6 = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
checkRelativePositions(ytext)
}
/**
* Testing https://github.com/yjs/yjs/issues/657
*
* @param {t.TestCase} tc
*/
export const testRelativePositionCase7 = tc => {
const docA = new Y.Doc()
const textA = docA.getText('text')
textA.insert(0, 'abcde')
// Create a relative position at index 2 in 'textA'
const relativePosition = Y.createRelativePositionFromTypeIndex(textA, 2)
// Verify that the absolutes positions on 'docA' are the same
const absolutePositionWithFollow =
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, true)
const absolutePositionWithoutFollow =
Y.createAbsolutePositionFromRelativePosition(relativePosition, docA, false)
t.assert(absolutePositionWithFollow?.index === 2)
t.assert(absolutePositionWithoutFollow?.index === 2)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionAssociationDifference = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, '2')
ytext.insert(0, '1')
const rposRight = Y.createRelativePositionFromTypeIndex(ytext, 1, 0)
const rposLeft = Y.createRelativePositionFromTypeIndex(ytext, 1, -1)
ytext.insert(1, 'x')
const posRight = Y.createAbsolutePositionFromRelativePosition(rposRight, ydoc)
const posLeft = Y.createAbsolutePositionFromRelativePosition(rposLeft, ydoc)
t.assert(posRight != null && posRight.index === 2)
t.assert(posLeft != null && posLeft.index === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testRelativePositionWithUndo = tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText()
ytext.insert(0, 'hello world')
const rpos = Y.createRelativePositionFromTypeIndex(ytext, 1)
const um = new Y.UndoManager(ytext)
ytext.delete(0, 6)
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 0)
um.undo()
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc)?.index === 1)
const posWithoutFollow = Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)
console.log({ posWithoutFollow })
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydoc, false)?.index === 6)
const ydocClone = new Y.Doc()
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone)?.index === 6)
t.assert(Y.createAbsolutePositionFromRelativePosition(rpos, ydocClone, false)?.index === 6)
}

View File

@ -1,223 +0,0 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper.js'
/**
* @param {t.TestCase} _tc
*/
export const testBasic = _tc => {
const ydoc = new Y.Doc({ gc: false })
ydoc.getText().insert(0, 'world!')
const snapshot = Y.snapshot(ydoc)
ydoc.getText().insert(0, 'hello ')
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
t.assert(restored.getText().toString() === 'world!')
}
/**
* @param {t.TestCase} _tc
*/
export const testBasicXmlAttributes = _tc => {
const ydoc = new Y.Doc({ gc: false })
const yxml = ydoc.getMap().set('el', new Y.XmlElement('div'))
const snapshot1 = Y.snapshot(ydoc)
yxml.setAttribute('a', '1')
const snapshot2 = Y.snapshot(ydoc)
yxml.setAttribute('a', '2')
t.compare(yxml.getAttributes(), { a: '2' })
t.compare(yxml.getAttributes(snapshot2), { a: '1' })
t.compare(yxml.getAttributes(snapshot1), {})
}
/**
* @param {t.TestCase} _tc
*/
export const testBasicRestoreSnapshot = _tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['hello'])
const snap = Y.snapshot(doc)
doc.getArray('array').insert(1, ['world'])
const docRestored = Y.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 Y.Doc({ gc: false })
const snap = Y.snapshot(doc)
snap.sv.set(9999, 0)
doc.getArray().insert(0, ['world'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.getArray().toArray(), [])
t.compare(doc.getArray().toArray(), ['world'])
// now this snapshot reflects the latest state. It should still work.
const snap2 = Y.snapshot(doc)
const docRestored2 = Y.createDocFromSnapshot(doc, snap2)
t.compare(docRestored2.getArray().toArray(), ['world'])
}
/**
* @param {t.TestCase} _tc
*/
export const testRestoreSnapshotWithSubType = _tc => {
const doc = new Y.Doc({ gc: false })
doc.getArray('array').insert(0, [new Y.Map()])
const subMap = doc.getArray('array').get(0)
subMap.set('key1', 'value1')
const snap = Y.snapshot(doc)
subMap.set('key2', 'value2')
const docRestored = Y.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 Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2'])
const snap = Y.snapshot(doc)
doc.getArray('array').delete(0)
const docRestored = Y.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 Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getMap('map').set('test', 1)
doc.getArray('array').insert(0, ['item0'])
const snap = Y.snapshot(doc)
doc.getArray('array').delete(1)
const docRestored = Y.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 Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1'])
doc.getArray('array').delete(0)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = Y.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 Y.Doc({ gc: false })
doc.getArray('array').insert(0, ['item1', 'item2', 'item3'])
doc.getArray('array').delete(1)
const snap = Y.snapshot(doc)
doc.getArray('array').insert(0, ['item0'])
const docRestored = Y.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 {Y.Doc}
*/
const doc0 = array0.doc
/**
* @type {Y.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 = Y.snapshot(array0.doc)
array0.insert(2, ['user1item2'])
testConnector.syncAll()
array1.insert(3, ['user2item2'])
testConnector.syncAll()
const docRestored0 = Y.createDocFromSnapshot(array0.doc, snap)
t.compare(docRestored0.getArray('array').toArray(), ['user1item1', 'user2item1'])
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
t.compare(docRestored1.getArray('array').toArray(), ['user1item1', 'user2item1'])
}
/**
* @param {t.TestCase} _tc
*/
export const testContainsUpdate = _tc => {
const ydoc = new Y.Doc()
/**
* @type {Array<Uint8Array>}
*/
const updates = []
ydoc.on('update', update => {
updates.push(update)
})
const yarr = ydoc.getArray()
const snapshot1 = Y.snapshot(ydoc)
yarr.insert(0, [1])
const snapshot2 = Y.snapshot(ydoc)
yarr.delete(0, 1)
const snapshotFinal = Y.snapshot(ydoc)
t.assert(!Y.snapshotContainsUpdate(snapshot1, updates[0]))
t.assert(!Y.snapshotContainsUpdate(snapshot2, updates[1]))
t.assert(Y.snapshotContainsUpdate(snapshot2, updates[0]))
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[0]))
t.assert(Y.snapshotContainsUpdate(snapshotFinal, updates[1]))
}

View File

@ -1,454 +0,0 @@
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as syncProtocol from 'y-protocols/sync'
import * as object from 'lib0/object'
import * as map from 'lib0/map'
import * as Y from '../src/index.js'
export * from '../src/index.js'
if (typeof window !== 'undefined') {
// @ts-ignore
window.Y = Y // eslint-disable-line
}
/**
* @param {TestYInstance} y // publish message created by `y` to all other online clients
* @param {Uint8Array} m
*/
const broadcastMessage = (y, m) => {
if (y.tc.onlineConns.has(y)) {
y.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== y) {
remoteYInstance._receive(m, y)
}
})
}
}
export let useV2 = false
export const encV1 = {
encodeStateAsUpdate: Y.encodeStateAsUpdate,
mergeUpdates: Y.mergeUpdates,
applyUpdate: Y.applyUpdate,
logUpdate: Y.logUpdate,
updateEventName: /** @type {'update'} */ ('update'),
diffUpdate: Y.diffUpdate
}
export const encV2 = {
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
mergeUpdates: Y.mergeUpdatesV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
updateEventName: /** @type {'updateV2'} */ ('updateV2'),
diffUpdate: Y.diffUpdateV2
}
export let enc = encV1
const useV1Encoding = () => {
useV2 = false
enc = encV1
}
const useV2Encoding = () => {
console.error('sync protocol doesnt support v2 protocol yet, fallback to v1 encoding') // @Todo
useV2 = false
enc = encV1
}
export class TestYInstance extends Y.Doc {
/**
* @param {TestConnector} testConnector
* @param {number} clientID
*/
constructor (testConnector, clientID) {
super()
this.userID = clientID // overwriting clientID
/**
* @type {TestConnector}
*/
this.tc = testConnector
/**
* @type {Map<TestYInstance, Array<Uint8Array>>}
*/
this.receiving = new Map()
testConnector.allConns.add(this)
/**
* The list of received updates.
* We are going to merge them later using Y.mergeUpdates and check if the resulting document is correct.
* @type {Array<Uint8Array>}
*/
this.updates = []
// set up observe on local model
this.on(enc.updateEventName, /** @param {Uint8Array} update @param {any} origin */ (update, origin) => {
if (origin !== testConnector) {
const encoder = encoding.createEncoder()
syncProtocol.writeUpdate(encoder, update)
broadcastMessage(this, encoding.toUint8Array(encoder))
}
this.updates.push(update)
})
this.connect()
}
/**
* Disconnect from TestConnector.
*/
disconnect () {
this.receiving = new Map()
this.tc.onlineConns.delete(this)
}
/**
* Append yourself to the list of known Y instances in testconnector.
* Also initiate sync with all clients.
*/
connect () {
if (!this.tc.onlineConns.has(this)) {
this.tc.onlineConns.add(this)
const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, this)
// publish SyncStep1
broadcastMessage(this, encoding.toUint8Array(encoder))
this.tc.onlineConns.forEach(remoteYInstance => {
if (remoteYInstance !== this) {
// remote instance sends instance to this instance
const encoder = encoding.createEncoder()
syncProtocol.writeSyncStep1(encoder, remoteYInstance)
this._receive(encoding.toUint8Array(encoder), remoteYInstance)
}
})
}
}
/**
* Receive a message from another client. This message is only appended to the list of receiving messages.
* TestConnector decides when this client actually reads this message.
*
* @param {Uint8Array} message
* @param {TestYInstance} remoteClient
*/
_receive (message, remoteClient) {
map.setIfUndefined(this.receiving, remoteClient, () => /** @type {Array<Uint8Array>} */ ([])).push(message)
}
}
/**
* Keeps track of TestYInstances.
*
* The TestYInstances add/remove themselves from the list of connections maiained in this object.
* I think it makes sense. Deal with it.
*/
export class TestConnector {
/**
* @param {prng.PRNG} gen
*/
constructor (gen) {
/**
* @type {Set<TestYInstance>}
*/
this.allConns = new Set()
/**
* @type {Set<TestYInstance>}
*/
this.onlineConns = new Set()
/**
* @type {prng.PRNG}
*/
this.prng = gen
}
/**
* Create a new Y instance and add it to the list of connections
* @param {number} clientID
*/
createY (clientID) {
return new TestYInstance(this, clientID)
}
/**
* Choose random connection and flush a random message from a random sender.
*
* If this function was unable to flush a message, because there are no more messages to flush, it returns false. true otherwise.
* @return {boolean}
*/
flushRandomMessage () {
const gen = this.prng
const conns = Array.from(this.onlineConns).filter(conn => conn.receiving.size > 0)
if (conns.length > 0) {
const receiver = prng.oneOf(gen, conns)
const [sender, messages] = prng.oneOf(gen, Array.from(receiver.receiving))
const m = messages.shift()
if (messages.length === 0) {
receiver.receiving.delete(sender)
}
if (m === undefined) {
return this.flushRandomMessage()
}
const encoder = encoding.createEncoder()
// console.log('receive (' + sender.userID + '->' + receiver.userID + '):\n', syncProtocol.stringifySyncMessage(decoding.createDecoder(m), receiver))
// do not publish data created when this function is executed (could be ss2 or update message)
syncProtocol.readSyncMessage(decoding.createDecoder(m), encoder, receiver, receiver.tc)
if (encoding.length(encoder) > 0) {
// send reply message
sender._receive(encoding.toUint8Array(encoder), receiver)
}
return true
}
return false
}
/**
* @return {boolean} True iff this function actually flushed something
*/
flushAllMessages () {
let didSomething = false
while (this.flushRandomMessage()) {
didSomething = true
}
return didSomething
}
reconnectAll () {
this.allConns.forEach(conn => conn.connect())
}
disconnectAll () {
this.allConns.forEach(conn => conn.disconnect())
}
syncAll () {
this.reconnectAll()
this.flushAllMessages()
}
/**
* @return {boolean} Whether it was possible to disconnect a randon connection.
*/
disconnectRandom () {
if (this.onlineConns.size === 0) {
return false
}
prng.oneOf(this.prng, Array.from(this.onlineConns)).disconnect()
return true
}
/**
* @return {boolean} Whether it was possible to reconnect a random connection.
*/
reconnectRandom () {
/**
* @type {Array<TestYInstance>}
*/
const reconnectable = []
this.allConns.forEach(conn => {
if (!this.onlineConns.has(conn)) {
reconnectable.push(conn)
}
})
if (reconnectable.length === 0) {
return false
}
prng.oneOf(this.prng, reconnectable).connect()
return true
}
}
/**
* @template T
* @param {t.TestCase} tc
* @param {{users?:number}} conf
* @param {InitTestObjectCallback<T>} [initTestObject]
* @return {{testObjects:Array<any>,testConnector:TestConnector,users:Array<TestYInstance>,array0:Y.Array<any>,array1:Y.Array<any>,array2:Y.Array<any>,map0:Y.Map<any>,map1:Y.Map<any>,map2:Y.Map<any>,map3:Y.Map<any>,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}}
*/
export const init = (tc, { users = 5 } = {}, initTestObject) => {
/**
* @type {Object<string,any>}
*/
const result = {
users: []
}
const gen = tc.prng
// choose an encoding approach at random
if (prng.bool(gen)) {
useV2Encoding()
} else {
useV1Encoding()
}
const testConnector = new TestConnector(gen)
result.testConnector = testConnector
for (let i = 0; i < users; i++) {
const y = testConnector.createY(i)
y.clientID = i
result.users.push(y)
result['array' + i] = y.getArray('array')
result['map' + i] = y.getMap('map')
result['xml' + i] = y.get('xml', Y.XmlElement)
result['text' + i] = y.getText('text')
}
testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
useV1Encoding()
return /** @type {any} */ (result)
}
/**
* 1. reconnect and flush all
* 2. user 0 gc
* 3. get type content
* 4. disconnect & reconnect all (so gc is propagated)
* 5. compare os, ds, ss
*
* @param {Array<TestYInstance>} users
*/
export const compare = users => {
users.forEach(u => u.connect())
while (users[0].tc.flushAllMessages()) {} // eslint-disable-line
// For each document, merge all received document updates with Y.mergeUpdates and create a new document which will be added to the list of "users"
// This ensures that mergeUpdates works correctly
const mergedDocs = users.map(user => {
const ydoc = new Y.Doc()
enc.applyUpdate(ydoc, enc.mergeUpdates(user.updates))
return ydoc
})
users.push(.../** @type {any} */(mergedDocs))
const userArrayValues = users.map(u => u.getArray('array').toJSON())
const userMapValues = users.map(u => u.getMap('map').toJSON())
const userXmlValues = users.map(u => u.get('xml', Y.XmlElement).toString())
const userTextValues = users.map(u => u.getText('text').toDelta())
for (const u of users) {
t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null)
}
// Test Array iterator
t.compare(users[0].getArray('array').toArray(), Array.from(users[0].getArray('array')))
// Test Map iterator
const ymapkeys = Array.from(users[0].getMap('map').keys())
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
/**
* @type {Object<string,any>}
*/
const mapRes = {}
for (const [k, v] of users[0].getMap('map')) {
mapRes[k] = v instanceof Y.AbstractType ? v.toJSON() : v
}
t.compare(userMapValues[0], mapRes)
// Compare all users
for (let i = 0; i < users.length - 1; i++) {
t.compare(userArrayValues[i].length, users[i].getArray('array').length)
t.compare(userArrayValues[i], userArrayValues[i + 1])
t.compare(userMapValues[i], userMapValues[i + 1])
t.compare(userXmlValues[i], userXmlValues[i + 1])
t.compare(userTextValues[i].map(/** @param {any} a */ a => typeof a.insert === 'string' ? a.insert : ' ').join('').length, users[i].getText('text').length)
t.compare(userTextValues[i], userTextValues[i + 1], '', (_constructor, a, b) => {
if (a instanceof Y.AbstractType) {
t.compare(a.toJSON(), b.toJSON())
} else if (a !== b) {
t.fail('Deltas dont match')
}
return true
})
t.compare(Y.encodeStateVector(users[i]), Y.encodeStateVector(users[i + 1]))
Y.equalDeleteSets(Y.createDeleteSetFromStructStore(users[i].store), Y.createDeleteSetFromStructStore(users[i + 1].store))
compareStructStores(users[i].store, users[i + 1].store)
t.compare(Y.encodeSnapshot(Y.snapshot(users[i])), Y.encodeSnapshot(Y.snapshot(users[i + 1])))
}
users.map(u => u.destroy())
}
/**
* @param {Y.Item?} a
* @param {Y.Item?} b
* @return {boolean}
*/
export const compareItemIDs = (a, b) => a === b || (a !== null && b != null && Y.compareIDs(a.id, b.id))
/**
* @param {import('../src/internals.js').StructStore} ss1
* @param {import('../src/internals.js').StructStore} ss2
*/
export const compareStructStores = (ss1, ss2) => {
t.assert(ss1.clients.size === ss2.clients.size)
for (const [client, structs1] of ss1.clients) {
const structs2 = /** @type {Array<Y.AbstractStruct>} */ (ss2.clients.get(client))
t.assert(structs2 !== undefined && structs1.length === structs2.length)
for (let i = 0; i < structs1.length; i++) {
const s1 = structs1[i]
const s2 = structs2[i]
// checks for abstract struct
if (
s1.constructor !== s2.constructor ||
!Y.compareIDs(s1.id, s2.id) ||
s1.deleted !== s2.deleted ||
// @ts-ignore
s1.length !== s2.length
) {
t.fail('Structs dont match')
}
if (s1 instanceof Y.Item) {
if (
!(s2 instanceof Y.Item) ||
!((s1.left === null && s2.left === null) || (s1.left !== null && s2.left !== null && Y.compareIDs(s1.left.lastId, s2.left.lastId))) ||
!compareItemIDs(s1.right, s2.right) ||
!Y.compareIDs(s1.origin, s2.origin) ||
!Y.compareIDs(s1.rightOrigin, s2.rightOrigin) ||
s1.parentSub !== s2.parentSub
) {
return t.fail('Items dont match')
}
// make sure that items are connected correctly
t.assert(s1.left === null || s1.left.right === s1)
t.assert(s1.right === null || s1.right.left === s1)
t.assert(s2.left === null || s2.left.right === s2)
t.assert(s2.right === null || s2.right.left === s2)
}
}
}
}
/**
* @template T
* @callback InitTestObjectCallback
* @param {TestYInstance} y
* @return {T}
*/
/**
* @template T
* @param {t.TestCase} tc
* @param {Array<function(Y.Doc,prng.PRNG,T):void>} mods
* @param {number} iterations
* @param {InitTestObjectCallback<T>} [initTestObject]
*/
export const applyRandomTests = (tc, mods, iterations, initTestObject) => {
const gen = tc.prng
const result = init(tc, { users: 5 }, initTestObject)
const { testConnector, users } = result
for (let i = 0; i < iterations; i++) {
if (prng.int32(gen, 0, 100) <= 2) {
// 2% chance to disconnect/reconnect a random user
if (prng.bool(gen)) {
testConnector.disconnectRandom()
} else {
testConnector.reconnectRandom()
}
} else if (prng.int32(gen, 0, 100) <= 1) {
// 1% chance to flush all
testConnector.flushAllMessages()
} else if (prng.int32(gen, 0, 100) <= 50) {
// 50% chance to flush a random message
testConnector.flushRandomMessage()
}
const user = prng.int32(gen, 0, users.length - 1)
const test = prng.oneOf(gen, mods)
test(users[user], gen, result.testObjects[user])
}
compare(users)
return result
}

View File

@ -1,813 +0,0 @@
import { init } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
export const testInconsistentFormat = () => {
/**
* @param {Y.Doc} ydoc
*/
const testYjsMerge = ydoc => {
const content = /** @type {Y.XmlText} */ (ydoc.get('text', Y.XmlText))
content.format(0, 6, { bold: null })
content.format(6, 4, { type: 'text' })
t.compare(content.toDelta(), [
{
attributes: { type: 'text' },
insert: 'Merge Test'
},
{
attributes: { type: 'text', italic: true },
insert: ' After'
}
])
}
const initializeYDoc = () => {
const yDoc = new Y.Doc({ gc: false })
const content = /** @type {Y.XmlText} */ (yDoc.get('text', Y.XmlText))
content.insert(0, ' After', { type: 'text', italic: true })
content.insert(0, 'Test', { type: 'text' })
content.insert(0, 'Merge ', { type: 'text', bold: true })
return yDoc
}
{
const yDoc = initializeYDoc()
testYjsMerge(yDoc)
}
{
const initialYDoc = initializeYDoc()
const yDoc = new Y.Doc({ gc: false })
Y.applyUpdate(yDoc, Y.encodeStateAsUpdate(initialYDoc))
testYjsMerge(yDoc)
}
}
/**
* @param {t.TestCase} tc
*/
export const testInfiniteCaptureTimeout = tc => {
const { array0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(array0, { captureTimeout: Number.MAX_VALUE })
array0.push([1, 2, 3])
undoManager.stopCapturing()
array0.push([4, 5, 6])
undoManager.undo()
t.compare(array0.toArray(), [1, 2, 3])
}
/**
* @param {t.TestCase} tc
*/
export const testUndoText = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
// items that are added & deleted in the same transaction won't be undo
text0.insert(0, 'test')
text0.delete(0, 4)
undoManager.undo()
t.assert(text0.toString() === '')
// follow redone items
text0.insert(0, 'a')
undoManager.stopCapturing()
text0.delete(0, 1)
undoManager.stopCapturing()
undoManager.undo()
t.assert(text0.toString() === 'a')
undoManager.undo()
t.assert(text0.toString() === '')
text0.insert(0, 'abc')
text1.insert(0, 'xyz')
testConnector.syncAll()
undoManager.undo()
t.assert(text0.toString() === 'xyz')
undoManager.redo()
t.assert(text0.toString() === 'abcxyz')
testConnector.syncAll()
text1.delete(0, 1)
testConnector.syncAll()
undoManager.undo()
t.assert(text0.toString() === 'xyz')
undoManager.redo()
t.assert(text0.toString() === 'bcxyz')
// test marks
text0.format(1, 3, { bold: true })
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
undoManager.undo()
t.compare(text0.toDelta(), [{ insert: 'bcxyz' }])
undoManager.redo()
t.compare(text0.toDelta(), [{ insert: 'b' }, { insert: 'cxy', attributes: { bold: true } }, { insert: 'z' }])
}
/**
* Test case to fix #241
* @param {t.TestCase} _tc
*/
export const testEmptyTypeScope = _tc => {
const ydoc = new Y.Doc()
const um = new Y.UndoManager([], { doc: ydoc })
const yarray = ydoc.getArray()
um.addToScope(yarray)
yarray.insert(0, [1])
um.undo()
t.assert(yarray.length === 0)
}
/**
* @param {t.TestCase} _tc
*/
export const testRejectUpdateExample = _tc => {
const tmpydoc1 = new Y.Doc()
tmpydoc1.getArray('restricted').insert(0, [1])
tmpydoc1.getArray('public').insert(0, [1])
const update1 = Y.encodeStateAsUpdate(tmpydoc1)
const tmpydoc2 = new Y.Doc()
tmpydoc2.getArray('public').insert(0, [2])
const update2 = Y.encodeStateAsUpdate(tmpydoc2)
const ydoc = new Y.Doc()
const restrictedType = ydoc.getArray('restricted')
/**
* Assume this function handles incoming updates via a communication channel like websockets.
* Changes to the `ydoc.getMap('restricted')` type should be rejected.
*
* - set up undo manager on the restricted types
* - cache pending* updates from the Ydoc to avoid certain attacks
* - apply received update and check whether the restricted type (or any of its children) has been changed.
* - catch errors that might try to circumvent the restrictions
* - undo changes on restricted types
* - reapply pending* updates
*
* @param {Uint8Array} update
*/
const updateHandler = (update) => {
// don't handle changes of the local undo manager, which is used to undo invalid changes
const um = new Y.UndoManager(restrictedType, { trackedOrigins: new Set(['remote change']) })
const beforePendingDs = ydoc.store.pendingDs
const beforePendingStructs = ydoc.store.pendingStructs?.update
try {
Y.applyUpdate(ydoc, update, 'remote change')
} finally {
while (um.undoStack.length) {
um.undo()
}
um.destroy()
ydoc.store.pendingDs = beforePendingDs
ydoc.store.pendingStructs = null
if (beforePendingStructs) {
Y.applyUpdateV2(ydoc, beforePendingStructs)
}
}
}
updateHandler(update1)
updateHandler(update2)
t.assert(restrictedType.length === 0)
t.assert(ydoc.getArray('public').length === 2)
}
/**
* Test case to fix #241
* @param {t.TestCase} _tc
*/
export const testGlobalScope = _tc => {
const ydoc = new Y.Doc()
const um = new Y.UndoManager(ydoc)
const yarray = ydoc.getArray()
yarray.insert(0, [1])
um.undo()
t.assert(yarray.length === 0)
}
/**
* 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
*/
export const testUndoMap = tc => {
const { testConnector, map0, map1 } = init(tc, { users: 2 })
map0.set('a', 0)
const undoManager = new Y.UndoManager(map0)
map0.set('a', 1)
undoManager.undo()
t.assert(map0.get('a') === 0)
undoManager.redo()
t.assert(map0.get('a') === 1)
// testing sub-types and if it can restore a whole type
const subType = new Y.Map()
map0.set('a', subType)
subType.set('x', 42)
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
undoManager.undo()
t.assert(map0.get('a') === 1)
undoManager.redo()
t.compare(map0.toJSON(), /** @type {any} */ ({ a: { x: 42 } }))
testConnector.syncAll()
// if content is overwritten by another user, undo operations should be skipped
map1.set('a', 44)
testConnector.syncAll()
undoManager.undo()
t.assert(map0.get('a') === 44)
undoManager.redo()
t.assert(map0.get('a') === 44)
// test setting value multiple times
map0.set('b', 'initial')
undoManager.stopCapturing()
map0.set('b', 'val1')
map0.set('b', 'val2')
undoManager.stopCapturing()
undoManager.undo()
t.assert(map0.get('b') === 'initial')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoArray = tc => {
const { testConnector, array0, array1 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(array0)
array0.insert(0, [1, 2, 3])
array1.insert(0, [4, 5, 6])
testConnector.syncAll()
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
undoManager.undo()
t.compare(array0.toArray(), [4, 5, 6])
undoManager.redo()
t.compare(array0.toArray(), [1, 2, 3, 4, 5, 6])
testConnector.syncAll()
array1.delete(0, 1) // user1 deletes [1]
testConnector.syncAll()
undoManager.undo()
t.compare(array0.toArray(), [4, 5, 6])
undoManager.redo()
t.compare(array0.toArray(), [2, 3, 4, 5, 6])
array0.delete(0, 5)
// test nested structure
const ymap = new Y.Map()
array0.insert(0, [ymap])
t.compare(array0.toJSON(), [{}])
undoManager.stopCapturing()
ymap.set('a', 1)
t.compare(array0.toJSON(), [{ a: 1 }])
undoManager.undo()
t.compare(array0.toJSON(), [{}])
undoManager.undo()
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON(), [{}])
undoManager.redo()
t.compare(array0.toJSON(), [{ a: 1 }])
testConnector.syncAll()
array1.get(0).set('b', 2)
testConnector.syncAll()
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
undoManager.undo()
t.compare(array0.toJSON(), [{ b: 2 }])
undoManager.undo()
t.compare(array0.toJSON(), [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON(), [{ b: 2 }])
undoManager.redo()
t.compare(array0.toJSON(), [{ a: 1, b: 2 }])
}
/**
* @param {t.TestCase} tc
*/
export const testUndoXml = tc => {
const { xml0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(xml0)
const child = new Y.XmlElement('p')
xml0.insert(0, [child])
const textchild = new Y.XmlText('content')
child.insert(0, [textchild])
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
// format textchild and revert that change
undoManager.stopCapturing()
textchild.format(3, 4, { bold: {} })
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>content</p></undefined>')
undoManager.redo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
xml0.delete(0, 1)
t.assert(xml0.toString() === '<undefined></undefined>')
undoManager.undo()
t.assert(xml0.toString() === '<undefined><p>con<bold>tent</bold></p></undefined>')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoEvents = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
let counter = 0
let receivedMetadata = -1
undoManager.on('stack-item-added', /** @param {any} event */ event => {
t.assert(event.type != null)
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
event.stackItem.meta.set('test', counter++)
})
undoManager.on('stack-item-popped', /** @param {any} event */ event => {
t.assert(event.type != null)
t.assert(event.changedParentTypes != null && event.changedParentTypes.has(text0))
receivedMetadata = event.stackItem.meta.get('test')
})
text0.insert(0, 'abc')
undoManager.undo()
t.assert(receivedMetadata === 0)
undoManager.redo()
t.assert(receivedMetadata === 1)
}
/**
* @param {t.TestCase} tc
*/
export const testTrackClass = tc => {
const { users, text0 } = init(tc, { users: 3 })
// only track origins that are numbers
const undoManager = new Y.UndoManager(text0, { trackedOrigins: new Set([Number]) })
users[0].transact(() => {
text0.insert(0, 'abc')
}, 42)
t.assert(text0.toString() === 'abc')
undoManager.undo()
t.assert(text0.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testTypeScope = tc => {
const { array0 } = init(tc, { users: 3 })
// only track origins that are numbers
const text0 = new Y.Text()
const text1 = new Y.Text()
array0.insert(0, [text0, text1])
const undoManager = new Y.UndoManager(text0)
const undoManagerBoth = new Y.UndoManager([text0, text1])
text1.insert(0, 'abc')
t.assert(undoManager.undoStack.length === 0)
t.assert(undoManagerBoth.undoStack.length === 1)
t.assert(text1.toString() === 'abc')
undoManager.undo()
t.assert(text1.toString() === 'abc')
undoManagerBoth.undo()
t.assert(text1.toString() === '')
}
/**
* @param {t.TestCase} tc
*/
export const testUndoInEmbed = tc => {
const { text0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(text0)
const nestedText = new Y.Text('initial text')
undoManager.stopCapturing()
text0.insertEmbed(0, nestedText, { bold: true })
t.assert(nestedText.toString() === 'initial text')
undoManager.stopCapturing()
nestedText.delete(0, nestedText.length)
nestedText.insert(0, 'other text')
t.assert(nestedText.toString() === 'other text')
undoManager.undo()
t.assert(nestedText.toString() === 'initial text')
undoManager.undo()
t.assert(text0.length === 0)
}
/**
* @param {t.TestCase} tc
*/
export const testUndoDeleteFilter = tc => {
/**
* @type {Y.Array<any>}
*/
const array0 = /** @type {any} */ (init(tc, { users: 3 }).array0)
const undoManager = new Y.UndoManager(array0, { deleteFilter: item => !(item instanceof Y.Item) || (item.content instanceof Y.ContentType && item.content.type._map.size === 0) })
const map0 = new Y.Map()
map0.set('hi', 1)
const map1 = new Y.Map()
array0.insert(0, [map0, map1])
undoManager.undo()
t.assert(array0.length === 1)
array0.get(0)
t.assert(Array.from(array0.get(0).keys()).length === 1)
}
/**
* This issue has been reported in https://discuss.yjs.dev/t/undomanager-with-external-updates/454/6
* @param {t.TestCase} _tc
*/
export const testUndoUntilChangePerformed = _tc => {
const doc = new Y.Doc()
const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update))
doc2.on('update', update => Y.applyUpdate(doc, update))
const yArray = doc.getArray('array')
const yArray2 = doc2.getArray('array')
const yMap = new Y.Map()
yMap.set('hello', 'world')
yArray.push([yMap])
const yMap2 = new Y.Map()
yMap2.set('key', 'value')
yArray.push([yMap2])
const undoManager = new Y.UndoManager([yArray], { trackedOrigins: new Set([doc.clientID]) })
const undoManager2 = new Y.UndoManager([doc2.get('array')], { trackedOrigins: new Set([doc2.clientID]) })
Y.transact(doc, () => yMap2.set('key', 'value modified'), doc.clientID)
undoManager.stopCapturing()
Y.transact(doc, () => yMap.set('hello', 'world modified'), doc.clientID)
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
undoManager2.undo()
undoManager.undo()
t.compareStrings(yMap2.get('key'), 'value')
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/317
* @param {t.TestCase} _tc
*/
export const testUndoNestedUndoIssue = _tc => {
const doc = new Y.Doc({ gc: false })
const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
/**
* @type {Y.Map<any>}
*/
const text = new Y.Map()
const blocks1 = new Y.Array()
const blocks1block = new Y.Map()
doc.transact(() => {
blocks1block.set('text', 'Type Something')
blocks1.push([blocks1block])
text.set('blocks', blocks1block)
design.set('text', text)
})
const blocks2 = new Y.Array()
const blocks2block = new Y.Map()
doc.transact(() => {
blocks2block.set('text', 'Something')
blocks2.push([blocks2block])
text.set('blocks', blocks2block)
})
const blocks3 = new Y.Array()
const blocks3block = new Y.Map()
doc.transact(() => {
blocks3block.set('text', 'Something Else')
blocks3.push([blocks3block])
text.set('blocks', blocks3block)
})
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.undo()
t.compare(design.toJSON(), { })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Type Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something' } } })
undoManager.redo()
t.compare(design.toJSON(), { text: { blocks: { text: 'Something Else' } } })
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/355
*
* @param {t.TestCase} _tc
*/
export const testConsecutiveRedoBug = _tc => {
const doc = new Y.Doc()
const yRoot = doc.getMap()
const undoMgr = new Y.UndoManager(yRoot)
let yPoint = new Y.Map()
yPoint.set('x', 0)
yPoint.set('y', 0)
yRoot.set('a', yPoint)
undoMgr.stopCapturing()
yPoint.set('x', 100)
yPoint.set('y', 100)
undoMgr.stopCapturing()
yPoint.set('x', 200)
yPoint.set('y', 200)
undoMgr.stopCapturing()
yPoint.set('x', 300)
yPoint.set('y', 300)
undoMgr.stopCapturing()
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
undoMgr.undo() // x=200, y=200
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
undoMgr.undo() // x=100, y=100
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
undoMgr.undo() // x=0, y=0
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
undoMgr.undo() // nil
t.compare(yRoot.get('a'), undefined)
undoMgr.redo() // x=0, y=0
yPoint = yRoot.get('a')
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
undoMgr.redo() // x=100, y=100
t.compare(yPoint.toJSON(), { x: 100, y: 100 })
undoMgr.redo() // x=200, y=200
t.compare(yPoint.toJSON(), { x: 200, y: 200 })
undoMgr.redo() // expected x=300, y=300, actually nil
t.compare(yPoint.toJSON(), { x: 300, y: 300 })
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/304
*
* @param {t.TestCase} _tc
*/
export const testUndoXmlBug = _tc => {
const origin = 'origin'
const doc = new Y.Doc()
const fragment = doc.getXmlFragment('t')
const undoManager = new Y.UndoManager(fragment, {
captureTimeout: 0,
trackedOrigins: new Set([origin])
})
// create element
doc.transact(() => {
const e = new Y.XmlElement('test-node')
e.setAttribute('a', '100')
e.setAttribute('b', '0')
fragment.insert(fragment.length, [e])
}, origin)
// change one attribute
doc.transact(() => {
const e = fragment.get(0)
e.setAttribute('a', '200')
}, origin)
// change both attributes
doc.transact(() => {
const e = fragment.get(0)
e.setAttribute('a', '180')
e.setAttribute('b', '50')
}, origin)
undoManager.undo()
undoManager.undo()
undoManager.undo()
undoManager.redo()
undoManager.redo()
undoManager.redo()
t.compare(fragment.toString(), '<test-node a="180" b="50"></test-node>')
}
/**
* This issue has been reported in https://github.com/yjs/yjs/issues/343
*
* @param {t.TestCase} _tc
*/
export const testUndoBlockBug = _tc => {
const doc = new Y.Doc({ gc: false })
const design = doc.getMap()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
const text = new Y.Map()
const blocks1 = new Y.Array()
const blocks1block = new Y.Map()
doc.transact(() => {
blocks1block.set('text', '1')
blocks1.push([blocks1block])
text.set('blocks', blocks1block)
design.set('text', text)
})
const blocks2 = new Y.Array()
const blocks2block = new Y.Map()
doc.transact(() => {
blocks2block.set('text', '2')
blocks2.push([blocks2block])
text.set('blocks', blocks2block)
})
const blocks3 = new Y.Array()
const blocks3block = new Y.Map()
doc.transact(() => {
blocks3block.set('text', '3')
blocks3.push([blocks3block])
text.set('blocks', blocks3block)
})
const blocks4 = new Y.Array()
const blocks4block = new Y.Map()
doc.transact(() => {
blocks4block.set('text', '4')
blocks4.push([blocks4block])
text.set('blocks', blocks4block)
})
// {"text":{"blocks":{"text":"4"}}}
undoManager.undo() // {"text":{"blocks":{"3"}}}
undoManager.undo() // {"text":{"blocks":{"text":"2"}}}
undoManager.undo() // {"text":{"blocks":{"text":"1"}}}
undoManager.undo() // {}
undoManager.redo() // {"text":{"blocks":{"text":"1"}}}
undoManager.redo() // {"text":{"blocks":{"text":"2"}}}
undoManager.redo() // {"text":{"blocks":{"text":"3"}}}
undoManager.redo() // {"text":{}}
t.compare(design.toJSON(), { text: { blocks: { text: '4' } } })
}
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} _tc
*/
export const testUndoDeleteTextFormat = _tc => {
const doc = new Y.Doc()
const text = doc.getText()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.getText()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const undoManager = new Y.UndoManager(text)
text.format(13, 7, { bold: true })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
text.format(16, 4, { bold: null })
undoManager.stopCapturing()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
undoManager.undo()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
const expect = [
{ insert: 'Attack ships ' },
{
insert: 'on fire',
attributes: { bold: true }
},
{ insert: ' off the shoulder of Orion.' }
]
t.compare(text.toDelta(), expect)
t.compare(text2.toDelta(), expect)
}
/**
* Undo text formatting delete should not corrupt peer state.
*
* @see https://github.com/yjs/yjs/issues/392
* @param {t.TestCase} _tc
*/
export const testBehaviorOfIgnoreremotemapchangesProperty = _tc => {
const doc = new Y.Doc()
const doc2 = new Y.Doc()
doc.on('update', update => Y.applyUpdate(doc2, update, doc))
doc2.on('update', update => Y.applyUpdate(doc, update, doc2))
const map1 = doc.getMap()
const map2 = doc2.getMap()
const um1 = new Y.UndoManager(map1, { ignoreRemoteMapChanges: true })
map1.set('x', 1)
map2.set('x', 2)
map1.set('x', 3)
map2.set('x', 4)
um1.undo()
t.assert(map1.get('x') === 2)
t.assert(map2.get('x') === 2)
}
/**
* Special deletion case.
*
* @see https://github.com/yjs/yjs/issues/447
* @param {t.TestCase} _tc
*/
export const testSpecialDeletionCase = _tc => {
const origin = 'undoable'
const doc = new Y.Doc()
const fragment = doc.getXmlFragment()
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
doc.transact(() => {
const e = new Y.XmlElement('test')
e.setAttribute('a', '1')
e.setAttribute('b', '2')
fragment.insert(0, [e])
})
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
doc.transact(() => {
// change attribute "b" and delete test-node
const e = fragment.get(0)
e.setAttribute('b', '3')
fragment.delete(0)
}, origin)
t.compareStrings(fragment.toString(), '')
undoManager.undo()
t.compareStrings(fragment.toString(), '<test a="1" b="2"></test>')
}
/**
* Deleted entries in a map should be restored on undo.
*
* @see https://github.com/yjs/yjs/issues/500
* @param {t.TestCase} tc
*/
export const testUndoDeleteInMap = (tc) => {
const { map0 } = init(tc, { users: 3 })
const undoManager = new Y.UndoManager(map0, { captureTimeout: 0 })
map0.set('a', 'a')
map0.delete('a')
map0.set('a', 'b')
map0.delete('a')
map0.set('a', 'c')
map0.delete('a')
map0.set('a', 'd')
t.compare(map0.toJSON(), { a: 'd' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'c' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'b' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON(), { a: 'a' })
}
/**
* It should expose the StackItem being processed if undoing
*
* @param {t.TestCase} _tc
*/
export const testUndoDoingStackItem = async (_tc) => {
const doc = new Y.Doc()
const text = doc.getText('text')
const undoManager = new Y.UndoManager([text])
undoManager.on('stack-item-added', /** @param {any} event */ event => {
event.stackItem.meta.set('str', '42')
})
let metaUndo = /** @type {any} */ (null)
let metaRedo = /** @type {any} */ (null)
text.observe((event) => {
const /** @type {Y.UndoManager} */ origin = event.transaction.origin
if (origin === undoManager && origin.undoing) {
metaUndo = origin.currStackItem?.meta.get('str')
} else if (origin === undoManager && origin.redoing) {
metaRedo = origin.currStackItem?.meta.get('str')
}
})
text.insert(0, 'abc')
undoManager.undo()
undoManager.redo()
t.compare(metaUndo, '42', 'currStackItem is accessible while undoing')
t.compare(metaRedo, '42', 'currStackItem is accessible while redoing')
t.compare(undoManager.currStackItem, null, 'currStackItem is null after observe/transaction')
}

View File

@ -1,357 +0,0 @@
import * as t from 'lib0/testing'
import { init, compare } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import { readClientsStructRefs, readDeleteSet, UpdateDecoderV2, UpdateEncoderV2, writeDeleteSet } from '../src/internals.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as object from 'lib0/object'
/**
* @typedef {Object} Enc
* @property {function(Array<Uint8Array>):Uint8Array} Enc.mergeUpdates
* @property {function(Y.Doc):Uint8Array} Enc.encodeStateAsUpdate
* @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate
* @property {function(Uint8Array):void} Enc.logUpdate
* @property {function(Uint8Array):{from:Map<number,number>,to:Map<number,number>}} Enc.parseUpdateMeta
* @property {function(Y.Doc):Uint8Array} Enc.encodeStateVector
* @property {function(Uint8Array):Uint8Array} Enc.encodeStateVectorFromUpdate
* @property {'update'|'updateV2'} Enc.updateEventName
* @property {string} Enc.description
* @property {function(Uint8Array, Uint8Array):Uint8Array} Enc.diffUpdate
*/
/**
* @type {Enc}
*/
const encV1 = {
mergeUpdates: Y.mergeUpdates,
encodeStateAsUpdate: Y.encodeStateAsUpdate,
applyUpdate: Y.applyUpdate,
logUpdate: Y.logUpdate,
parseUpdateMeta: Y.parseUpdateMeta,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdate,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'update',
description: 'V1',
diffUpdate: Y.diffUpdate
}
/**
* @type {Enc}
*/
const encV2 = {
mergeUpdates: Y.mergeUpdatesV2,
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
parseUpdateMeta: Y.parseUpdateMetaV2,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'updateV2',
description: 'V2',
diffUpdate: Y.diffUpdateV2
}
/**
* @type {Enc}
*/
const encDoc = {
mergeUpdates: (updates) => {
const ydoc = new Y.Doc({ gc: false })
updates.forEach(update => {
Y.applyUpdateV2(ydoc, update)
})
return Y.encodeStateAsUpdateV2(ydoc)
},
encodeStateAsUpdate: Y.encodeStateAsUpdateV2,
applyUpdate: Y.applyUpdateV2,
logUpdate: Y.logUpdateV2,
parseUpdateMeta: Y.parseUpdateMetaV2,
encodeStateVectorFromUpdate: Y.encodeStateVectorFromUpdateV2,
encodeStateVector: Y.encodeStateVector,
updateEventName: 'updateV2',
description: 'Merge via Y.Doc',
/**
* @param {Uint8Array} update
* @param {Uint8Array} sv
*/
diffUpdate: (update, sv) => {
const ydoc = new Y.Doc({ gc: false })
Y.applyUpdateV2(ydoc, update)
return Y.encodeStateAsUpdateV2(ydoc, sv)
}
}
const encoders = [encV1, encV2, encDoc]
/**
* @param {Array<Y.Doc>} users
* @param {Enc} enc
*/
const fromUpdates = (users, enc) => {
const updates = users.map(user =>
enc.encodeStateAsUpdate(user)
)
const ydoc = new Y.Doc()
enc.applyUpdate(ydoc, enc.mergeUpdates(updates))
return ydoc
}
/**
* @param {t.TestCase} tc
*/
export const testMergeUpdates = tc => {
const { users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, [1])
array1.insert(0, [2])
compare(users)
encoders.forEach(enc => {
const merged = fromUpdates(users, enc)
t.compareArrays(array0.toArray(), merged.getArray('array').toArray())
})
}
/**
* @param {t.TestCase} tc
*/
export const testKeyEncoding = tc => {
const { users, text0, text1 } = init(tc, { users: 2 })
text0.insert(0, 'a', { italic: true })
text0.insert(0, 'b')
text0.insert(0, 'c', { italic: true })
const update = Y.encodeStateAsUpdateV2(users[0])
Y.applyUpdateV2(users[1], update)
t.compare(text1.toDelta(), [{ insert: 'c', attributes: { italic: true } }, { insert: 'b' }, { insert: 'a', attributes: { italic: true } }])
compare(users)
}
/**
* @param {Y.Doc} ydoc
* @param {Array<Uint8Array>} updates - expecting at least 4 updates
* @param {Enc} enc
* @param {boolean} hasDeletes
*/
const checkUpdateCases = (ydoc, updates, enc, hasDeletes) => {
const cases = []
// Case 1: Simple case, simply merge everything
cases.push(enc.mergeUpdates(updates))
// Case 2: Overlapping updates
cases.push(enc.mergeUpdates([
enc.mergeUpdates(updates.slice(2)),
enc.mergeUpdates(updates.slice(0, 2))
]))
// Case 3: Overlapping updates
cases.push(enc.mergeUpdates([
enc.mergeUpdates(updates.slice(2)),
enc.mergeUpdates(updates.slice(1, 3)),
updates[0]
]))
// Case 4: Separated updates (containing skips)
cases.push(enc.mergeUpdates([
enc.mergeUpdates([updates[0], updates[2]]),
enc.mergeUpdates([updates[1], updates[3]]),
enc.mergeUpdates(updates.slice(4))
]))
// Case 5: overlapping with many duplicates
cases.push(enc.mergeUpdates(cases))
// const targetState = enc.encodeStateAsUpdate(ydoc)
// t.info('Target State: ')
// enc.logUpdate(targetState)
cases.forEach((mergedUpdates) => {
// t.info('State Case $' + i + ':')
// enc.logUpdate(updates)
const merged = new Y.Doc({ gc: false })
enc.applyUpdate(merged, mergedUpdates)
t.compareArrays(merged.getArray().toArray(), ydoc.getArray().toArray())
t.compare(enc.encodeStateVector(merged), enc.encodeStateVectorFromUpdate(mergedUpdates))
if (enc.updateEventName !== 'update') { // @todo should this also work on legacy updates?
for (let j = 1; j < updates.length; j++) {
const partMerged = enc.mergeUpdates(updates.slice(j))
const partMeta = enc.parseUpdateMeta(partMerged)
const targetSV = Y.encodeStateVectorFromUpdateV2(Y.mergeUpdatesV2(updates.slice(0, j)))
const diffed = enc.diffUpdate(mergedUpdates, targetSV)
const diffedMeta = enc.parseUpdateMeta(diffed)
t.compare(partMeta, diffedMeta)
{
// We can'd do the following
// - t.compare(diffed, mergedDeletes)
// because diffed contains the set of all deletes.
// So we add all deletes from `diffed` to `partDeletes` and compare then
const decoder = decoding.createDecoder(diffed)
const updateDecoder = new UpdateDecoderV2(decoder)
readClientsStructRefs(updateDecoder, new Y.Doc())
const ds = readDeleteSet(updateDecoder)
const updateEncoder = new UpdateEncoderV2()
encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs
writeDeleteSet(updateEncoder, ds)
const deletesUpdate = updateEncoder.toUint8Array()
const mergedDeletes = Y.mergeUpdatesV2([deletesUpdate, partMerged])
if (!hasDeletes || enc !== encDoc) {
// deletes will almost definitely lead to different encoders because of the mergeStruct feature that is present in encDoc
t.compare(diffed, mergedDeletes)
}
}
}
}
const meta = enc.parseUpdateMeta(mergedUpdates)
meta.from.forEach((clock, client) => t.assert(clock === 0))
meta.to.forEach((clock, client) => {
const structs = /** @type {Array<Y.Item>} */ (merged.store.clients.get(client))
const lastStruct = structs[structs.length - 1]
t.assert(lastStruct.id.clock + lastStruct.length === clock)
})
})
}
/**
* @param {t.TestCase} _tc
*/
export const testMergeUpdates1 = _tc => {
encoders.forEach((enc) => {
t.info(`Using encoder: ${enc.description}`)
const ydoc = new Y.Doc({ gc: false })
const updates = /** @type {Array<Uint8Array>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.getArray()
array.insert(0, [1])
array.insert(0, [2])
array.insert(0, [3])
array.insert(0, [4])
checkUpdateCases(ydoc, updates, enc, false)
})
}
/**
* @param {t.TestCase} tc
*/
export const testMergeUpdates2 = tc => {
encoders.forEach((enc, i) => {
t.info(`Using encoder: ${enc.description}`)
const ydoc = new Y.Doc({ gc: false })
const updates = /** @type {Array<Uint8Array>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.getArray()
array.insert(0, [1, 2])
array.delete(1, 1)
array.insert(0, [3, 4])
array.delete(1, 2)
checkUpdateCases(ydoc, updates, enc, true)
})
}
/**
* @param {t.TestCase} tc
*/
export const testMergePendingUpdates = tc => {
const yDoc = new Y.Doc()
/**
* @type {Array<Uint8Array>}
*/
const serverUpdates = []
yDoc.on('update', (update, origin, c) => {
serverUpdates.splice(serverUpdates.length, 0, update)
})
const yText = yDoc.getText('textBlock')
yText.applyDelta([{ insert: 'r' }])
yText.applyDelta([{ insert: 'o' }])
yText.applyDelta([{ insert: 'n' }])
yText.applyDelta([{ insert: 'e' }])
yText.applyDelta([{ insert: 'n' }])
const yDoc1 = new Y.Doc()
Y.applyUpdate(yDoc1, serverUpdates[0])
const update1 = Y.encodeStateAsUpdate(yDoc1)
const yDoc2 = new Y.Doc()
Y.applyUpdate(yDoc2, update1)
Y.applyUpdate(yDoc2, serverUpdates[1])
const update2 = Y.encodeStateAsUpdate(yDoc2)
const yDoc3 = new Y.Doc()
Y.applyUpdate(yDoc3, update2)
Y.applyUpdate(yDoc3, serverUpdates[3])
const update3 = Y.encodeStateAsUpdate(yDoc3)
const yDoc4 = new Y.Doc()
Y.applyUpdate(yDoc4, update3)
Y.applyUpdate(yDoc4, serverUpdates[2])
const update4 = Y.encodeStateAsUpdate(yDoc4)
const yDoc5 = new Y.Doc()
Y.applyUpdate(yDoc5, update4)
Y.applyUpdate(yDoc5, serverUpdates[4])
// @ts-ignore
const _update5 = Y.encodeStateAsUpdate(yDoc5) // eslint-disable-line
const yText5 = yDoc5.getText('textBlock')
t.compareStrings(yText5.toString(), 'nenor')
}
/**
* @param {t.TestCase} _tc
*/
export const testObfuscateUpdates = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.getText('text')
const ymap = ydoc.getMap('map')
const yarray = ydoc.getArray('array')
// test ytext
ytext.applyDelta([{ insert: 'text', attributes: { bold: true } }, { insert: { href: 'supersecreturl' } }])
// test ymap
ymap.set('key', 'secret1')
ymap.set('key', 'secret2')
// test yarray with subtype & subdoc
const subtype = new Y.XmlElement('secretnodename')
const subdoc = new Y.Doc({ guid: 'secret' })
subtype.setAttribute('attr', 'val')
yarray.insert(0, ['teststring', 42, subtype, subdoc])
// obfuscate the content and put it into a new document
const obfuscatedUpdate = Y.obfuscateUpdate(Y.encodeStateAsUpdate(ydoc))
const odoc = new Y.Doc()
Y.applyUpdate(odoc, obfuscatedUpdate)
const otext = odoc.getText('text')
const omap = odoc.getMap('map')
const oarray = odoc.getArray('array')
// test ytext
const delta = otext.toDelta()
t.assert(delta.length === 2)
t.assert(delta[0].insert !== 'text' && delta[0].insert.length === 4)
t.assert(object.length(delta[0].attributes) === 1)
t.assert(!object.hasProperty(delta[0].attributes, 'bold'))
t.assert(object.length(delta[1]) === 1)
t.assert(object.hasProperty(delta[1], 'insert'))
// test ymap
t.assert(omap.size === 1)
t.assert(!omap.has('key'))
// test yarray with subtype & subdoc
const result = oarray.toArray()
t.assert(result.length === 4)
t.assert(result[0] !== 'teststring')
t.assert(result[1] !== 42)
const osubtype = /** @type {Y.XmlElement} */ (result[2])
const osubdoc = result[3]
// test subtype
t.assert(osubtype.nodeName !== subtype.nodeName)
t.assert(object.length(osubtype.getAttributes()) === 1)
t.assert(osubtype.getAttribute('attr') === undefined)
// test subdoc
t.assert(osubdoc.guid !== subdoc.guid)
}

View File

@ -1,691 +0,0 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as math from 'lib0/math'
import * as env from 'lib0/environment'
const isDevMode = env.getVariable('node_env') === 'development'
/**
* @param {t.TestCase} tc
*/
export const testBasicUpdate = tc => {
const doc1 = new Y.Doc()
const doc2 = new Y.Doc()
doc1.getArray('array').insert(0, ['hi'])
const update = Y.encodeStateAsUpdate(doc1)
Y.applyUpdate(doc2, update)
t.compare(doc2.getArray('array').toArray(), ['hi'])
}
/**
* @param {t.TestCase} tc
*/
export const testFailsObjectManipulationInDevMode = tc => {
if (isDevMode) {
t.info('running in dev mode')
const doc = new Y.Doc()
const a = [1, 2, 3]
const b = { o: 1 }
doc.getArray('test').insert(0, [a])
doc.getMap('map').set('k', b)
t.fails(() => {
a[0] = 42
})
t.fails(() => {
b.o = 42
})
} else {
t.info('not in dev mode')
}
}
/**
* @param {t.TestCase} tc
*/
export const testSlice = tc => {
const doc1 = new Y.Doc()
const arr = doc1.getArray('array')
arr.insert(0, [1, 2, 3])
t.compareArrays(arr.slice(0), [1, 2, 3])
t.compareArrays(arr.slice(1), [2, 3])
t.compareArrays(arr.slice(0, -1), [1, 2])
arr.insert(0, [0])
t.compareArrays(arr.slice(0), [0, 1, 2, 3])
t.compareArrays(arr.slice(0, 2), [0, 1])
}
/**
* @param {t.TestCase} tc
*/
export const testArrayFrom = tc => {
const doc1 = new Y.Doc()
const db1 = doc1.getMap('root')
const nestedArray1 = Y.Array.from([0, 1, 2])
db1.set('array', nestedArray1)
t.compare(nestedArray1.toArray(), [0, 1, 2])
}
/**
* Debugging yjs#297 - a critical bug connected to the search-marker approach
*
* @param {t.TestCase} tc
*/
export const testLengthIssue = tc => {
const doc1 = new Y.Doc()
const arr = doc1.getArray('array')
arr.push([0, 1, 2, 3])
arr.delete(0)
arr.insert(0, [0])
t.assert(arr.length === arr.toArray().length)
doc1.transact(() => {
arr.delete(1)
t.assert(arr.length === arr.toArray().length)
arr.insert(1, [1])
t.assert(arr.length === arr.toArray().length)
arr.delete(2)
t.assert(arr.length === arr.toArray().length)
arr.insert(2, [2])
t.assert(arr.length === arr.toArray().length)
})
t.assert(arr.length === arr.toArray().length)
arr.delete(1)
t.assert(arr.length === arr.toArray().length)
arr.insert(1, [1])
t.assert(arr.length === arr.toArray().length)
}
/**
* Debugging yjs#314
*
* @param {t.TestCase} tc
*/
export const testLengthIssue2 = tc => {
const doc = new Y.Doc()
const next = doc.getArray()
doc.transact(() => {
next.insert(0, ['group2'])
})
doc.transact(() => {
next.insert(1, ['rectangle3'])
})
doc.transact(() => {
next.delete(0)
next.insert(0, ['rectangle3'])
})
next.delete(1)
doc.transact(() => {
next.insert(1, ['ellipse4'])
})
doc.transact(() => {
next.insert(2, ['ellipse3'])
})
doc.transact(() => {
next.insert(3, ['ellipse2'])
})
doc.transact(() => {
doc.transact(() => {
t.fails(() => {
next.insert(5, ['rectangle2'])
})
next.insert(4, ['rectangle2'])
})
doc.transact(() => {
// this should not throw an error message
next.delete(4)
})
})
console.log(next.toArray())
}
/**
* @param {t.TestCase} tc
*/
export const testDeleteInsert = tc => {
const { users, array0 } = init(tc, { users: 2 })
array0.delete(0, 0)
t.describe('Does not throw when deleting zero elements with position 0')
t.fails(() => {
array0.delete(1, 1)
})
array0.insert(0, ['A'])
array0.delete(1, 0)
t.describe('Does not throw when deleting zero elements with valid position 1')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertThreeElementsTryRegetProperty = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, [1, true, false])
t.compare(array0.toJSON(), [1, true, false], '.toJSON() works')
testConnector.flushAllMessages()
t.compare(array1.toJSON(), [1, true, false], '.toJSON() works after sync')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testConcurrentInsertWithThreeConflicts = tc => {
const { users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, [0])
array1.insert(0, [1])
array2.insert(0, [2])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testConcurrentInsertDeleteWithThreeConflicts = tc => {
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
array0.insert(1, [0])
array1.delete(0)
array1.delete(1, 1)
array2.insert(1, [2])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertionsInLateSync = tc => {
const { testConnector, users, array0, array1, array2 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
users[2].disconnect()
array0.insert(1, ['user0'])
array1.insert(1, ['user1'])
array2.insert(1, ['user2'])
users[1].connect()
users[2].connect()
testConnector.flushAllMessages()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testDisconnectReallyPreventsSendingMessages = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
users[2].disconnect()
array0.insert(1, ['user0'])
array1.insert(1, ['user1'])
t.compare(array0.toJSON(), ['x', 'user0', 'y'])
t.compare(array1.toJSON(), ['x', 'user1', 'y'])
users[1].connect()
users[2].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testDeletionsInLateSync = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, ['x', 'y'])
testConnector.flushAllMessages()
users[1].disconnect()
array1.delete(1, 1)
array0.delete(0, 2)
users[1].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertThenMergeDeleteOnSync = tc => {
const { testConnector, users, array0, array1 } = init(tc, { users: 2 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
users[0].disconnect()
array1.delete(0, 3)
users[0].connect()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Object<string,any>?}
*/
let event = null
array0.observe(e => {
event = e
})
array0.insert(0, [0, 1, 2])
t.assert(event !== null)
event = null
array0.delete(0)
t.assert(event !== null)
event = null
array0.delete(0, 2)
t.assert(event !== null)
event = null
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testNestedObserverEvents = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<number>}
*/
const vals = []
array0.observe(e => {
if (array0.length === 1) {
// inserting, will call this observer again
// we expect that this observer is called after this event handler finishedn
array0.insert(1, [1])
vals.push(0)
} else {
// this should be called the second time an element is inserted (above case)
vals.push(1)
}
})
array0.insert(0, [0])
t.compareArrays(vals, [0, 1])
t.compareArrays(array0.toArray(), [0, 1])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testInsertAndDeleteEventsForTypes = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Object<string,any>|null}
*/
let event = null
array0.observe(e => {
event = e
})
array0.insert(0, [new Y.Array()])
t.assert(event !== null)
event = null
array0.delete(0)
t.assert(event !== null)
event = null
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')
}
}
/**
* Correct index when computing event.path in observeDeep - https://github.com/yjs/yjs/issues/457
*
* @param {t.TestCase} _tc
*/
export const testObservedeepIndexes = _tc => {
const doc = new Y.Doc()
const map = doc.getMap()
// Create a field with the array as value
map.set('my-array', new Y.Array())
// Fill the array with some strings and our Map
map.get('my-array').push(['a', 'b', 'c', new Y.Map()])
/**
* @type {Array<any>}
*/
let eventPath = []
map.observeDeep((events) => { eventPath = events[0].path })
// set a value on the map inside of our array
map.get('my-array').get(3).set('hello', 'world')
console.log(eventPath)
t.compare(eventPath, ['my-array', 3])
}
/**
* @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
*/
export const testInsertAndDeleteEventsForTypes2 = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {Array<Object<string,any>>}
*/
const events = []
array0.observe(e => {
events.push(e)
})
array0.insert(0, ['hi', new Y.Map()])
t.assert(events.length === 1, 'Event is triggered exactly once for insertion of two elements')
array0.delete(1)
t.assert(events.length === 2, 'Event is triggered exactly once for deletion')
compare(users)
}
/**
* This issue has been reported here https://github.com/yjs/yjs/issues/155
* @param {t.TestCase} tc
*/
export const testNewChildDoesNotEmitEventInTransaction = tc => {
const { array0, users } = init(tc, { users: 2 })
let fired = false
users[0].transact(() => {
const newMap = new Y.Map()
newMap.observe(() => {
fired = true
})
array0.insert(0, [newMap])
newMap.set('tst', 42)
})
t.assert(!fired, 'Event does not trigger')
}
/**
* @param {t.TestCase} tc
*/
export const testGarbageCollector = tc => {
const { testConnector, users, array0 } = init(tc, { users: 3 })
array0.insert(0, ['x', 'y', 'z'])
testConnector.flushAllMessages()
users[0].disconnect()
array0.delete(0, 3)
users[0].connect()
testConnector.flushAllMessages()
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEventTargetIsSetCorrectlyOnLocal = tc => {
const { array0, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event
array0.observe(e => {
event = e
})
array0.insert(0, ['stuff'])
t.assert(event.target === array0, '"target" property is set correctly')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEventTargetIsSetCorrectlyOnRemote = tc => {
const { testConnector, array0, array1, users } = init(tc, { users: 3 })
/**
* @type {any}
*/
let event
array0.observe(e => {
event = e
})
array1.insert(0, ['stuff'])
testConnector.flushAllMessages()
t.assert(event.target === array0, '"target" property is set correctly')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testIteratingArrayContainingTypes = tc => {
const y = new Y.Doc()
const arr = y.getArray('arr')
const numItems = 10
for (let i = 0; i < numItems; i++) {
const map = new Y.Map()
map.set('value', i)
arr.push([map])
}
let cnt = 0
for (const item of arr) {
t.assert(item.get('value') === cnt++, 'value is correct')
}
y.destroy()
}
let _uniqueNumber = 0
const getUniqueNumber = () => _uniqueNumber++
/**
* @type {Array<function(Doc,prng.PRNG,any):void>}
*/
const arrayTransactions = [
function insert (user, gen) {
const yarray = user.getArray('array')
const uniqueNumber = getUniqueNumber()
const content = []
const len = prng.int32(gen, 1, 4)
for (let i = 0; i < len; i++) {
content.push(uniqueNumber)
}
const pos = prng.int32(gen, 0, yarray.length)
const oldContent = yarray.toArray()
yarray.insert(pos, content)
oldContent.splice(pos, 0, ...content)
t.compareArrays(yarray.toArray(), oldContent) // we want to make sure that fastSearch markers insert at the correct position
},
function insertTypeArray (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Array()])
const array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Map()])
const map = yarray.get(pos)
map.set('someprop', 42)
map.set('someprop', 43)
map.set('someprop', 44)
},
function insertTypeNull (user, gen) {
const yarray = user.getArray('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [null])
},
function _delete (user, gen) {
const yarray = user.getArray('array')
const length = yarray.length
if (length > 0) {
let somePos = prng.int32(gen, 0, length - 1)
let delLength = prng.int32(gen, 1, math.min(2, length - somePos))
if (prng.bool(gen)) {
const type = yarray.get(somePos)
if (type instanceof Y.Array && type.length > 0) {
somePos = prng.int32(gen, 0, type.length - 1)
delLength = prng.int32(gen, 0, math.min(2, type.length - somePos))
type.delete(somePos, delLength)
}
} else {
const oldContent = yarray.toArray()
yarray.delete(somePos, delLength)
oldContent.splice(somePos, delLength)
t.compareArrays(yarray.toArray(), oldContent)
}
}
}
]
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests6 = tc => {
applyRandomTests(tc, arrayTransactions, 6)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests40 = tc => {
applyRandomTests(tc, arrayTransactions, 40)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests42 = tc => {
applyRandomTests(tc, arrayTransactions, 42)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests43 = tc => {
applyRandomTests(tc, arrayTransactions, 43)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests44 = tc => {
applyRandomTests(tc, arrayTransactions, 44)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests45 = tc => {
applyRandomTests(tc, arrayTransactions, 45)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests46 = tc => {
applyRandomTests(tc, arrayTransactions, 46)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests300 = tc => {
applyRandomTests(tc, arrayTransactions, 300)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests400 = tc => {
applyRandomTests(tc, arrayTransactions, 400)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests500 = tc => {
applyRandomTests(tc, arrayTransactions, 500)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests600 = tc => {
applyRandomTests(tc, arrayTransactions, 600)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1000 = tc => {
applyRandomTests(tc, arrayTransactions, 1000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests1800 = tc => {
applyRandomTests(tc, arrayTransactions, 1800)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests3000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 3000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests5000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 5000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYarrayTests30000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, arrayTransactions, 30000)
}

View File

@ -1,756 +0,0 @@
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import {
compareIDs
} from '../src/internals.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
/**
* @param {t.TestCase} _tc
*/
export const testIterators = _tc => {
const ydoc = new Y.Doc()
/**
* @type {Y.Map<number>}
*/
const ymap = ydoc.getMap()
// we are only checking if the type assumptions are correct
/**
* @type {Array<number>}
*/
const vals = Array.from(ymap.values())
/**
* @type {Array<[string,number]>}
*/
const entries = Array.from(ymap.entries())
/**
* @type {Array<string>}
*/
const keys = Array.from(ymap.keys())
console.log(vals, entries, keys)
}
/**
* Computing event changes after transaction should result in an error. See yjs#539
*
* @param {t.TestCase} _tc
*/
export const testMapEventError = _tc => {
const doc = new Y.Doc()
const ymap = doc.getMap()
/**
* @type {any}
*/
let event = null
ymap.observe((e) => {
event = e
})
t.fails(() => {
t.info(event.keys)
})
t.fails(() => {
t.info(event.keys)
})
}
/**
* @param {t.TestCase} tc
*/
export const testMapHavingIterableAsConstructorParamTests = tc => {
const { map0 } = init(tc, { users: 1 })
const m1 = new Y.Map(Object.entries({ number: 1, string: 'hello' }))
map0.set('m1', m1)
t.assert(m1.get('number') === 1)
t.assert(m1.get('string') === 'hello')
const m2 = new Y.Map([
['object', { x: 1 }],
['boolean', true]
])
map0.set('m2', m2)
t.assert(m2.get('object').x === 1)
t.assert(m2.get('boolean') === true)
const m3 = new Y.Map([...m1, ...m2])
map0.set('m3', m3)
t.assert(m3.get('number') === 1)
t.assert(m3.get('string') === 'hello')
t.assert(m3.get('object').x === 1)
t.assert(m3.get('boolean') === true)
}
/**
* @param {t.TestCase} tc
*/
export const testBasicMapTests = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
users[2].disconnect()
map0.set('null', null)
map0.set('number', 1)
map0.set('string', 'hello Y')
map0.set('object', { key: { key2: 'value' } })
map0.set('y-map', new Y.Map())
map0.set('boolean1', true)
map0.set('boolean0', false)
const map = map0.get('y-map')
map.set('y-array', new Y.Array())
const array = map.get('y-array')
array.insert(0, [0])
array.insert(0, [-1])
t.assert(map0.get('null') === null, 'client 0 computed the change (null)')
t.assert(map0.get('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.get('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.get('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.get('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.get('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.get('y-map').get('y-array').get(0) === -1, 'client 0 computed the change (type)')
t.assert(map0.size === 7, 'client 0 map has correct size')
users[2].connect()
testConnector.flushAllMessages()
t.assert(map1.get('null') === null, 'client 1 received the update (null)')
t.assert(map1.get('number') === 1, 'client 1 received the update (number)')
t.assert(map1.get('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.get('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.get('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.get('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.get('y-map').get('y-array').get(0) === -1, 'client 1 received the update (type)')
t.assert(map1.size === 7, 'client 1 map has correct size')
// compare disconnected user
t.assert(map2.get('null') === null, 'client 2 received the update (null) - was disconnected')
t.assert(map2.get('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.get('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.get('boolean0') === false, 'client 2 computed the change (boolean)')
t.assert(map2.get('boolean1') === true, 'client 2 computed the change (boolean)')
t.compare(map2.get('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
t.assert(map2.get('y-map').get('y-array').get(0) === -1, 'client 2 received the update (type) - was disconnected')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapProperty = tc => {
const { testConnector, users, map0 } = init(tc, { users: 2 })
map0.set('stuff', 'stuffy')
map0.set('undefined', undefined)
map0.set('null', null)
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
t.assert(u.get('undefined') === undefined, 'undefined')
t.compare(u.get('null'), null, 'null')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYmap = tc => {
const { users, map0 } = init(tc, { users: 2 })
const map = map0.set('Map', new Y.Map())
t.assert(map0.get('Map') === map)
map.set('one', 1)
t.compare(map.get('one'), 1)
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYarray = tc => {
const { users, map0 } = init(tc, { users: 2 })
const array = map0.set('Array', new Y.Array())
t.assert(array === map0.get('Array'))
array.insert(0, [1, 2, 3])
// @ts-ignore
t.compare(map0.toJSON(), { Array: [1, 2, 3] })
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertySyncs = tc => {
const { testConnector, users, map0 } = init(tc, { users: 2 })
map0.set('stuff', 'stuffy')
t.compare(map0.get('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'stuffy')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithConflict = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'c1')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSizeAndDeleteOfMapProperty = tc => {
const { map0 } = init(tc, { users: 1 })
map0.set('stuff', 'c0')
map0.set('otherstuff', 'c1')
t.assert(map0.size === 2, `map size is ${map0.size} expected 2`)
map0.delete('stuff')
t.assert(map0.size === 1, `map size after delete is ${map0.size}, expected 1`)
map0.delete('otherstuff')
t.assert(map0.size === 0, `map size after delete is ${map0.size}, expected 0`)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapProperty = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapProperties = tc => {
const { testConnector, users, map0 } = init(tc, { users: 1 })
map0.set('stuff', 'c0')
map0.set('otherstuff', 'c1')
map0.clear()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
t.assert(u.get('otherstuff') === undefined)
t.assert(u.size === 0, `map size after clear is ${u.size}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapPropertiesWithConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
map0.set('otherstuff', 'c0')
map1.set('otherstuff', 'c1')
map2.set('otherstuff', 'c2')
map3.set('otherstuff', 'c3')
map3.clear()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
t.assert(u.get('otherstuff') === undefined)
t.assert(u.size === 0, `map size after clear is ${u.size}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.compare(u.get('stuff'), 'c3')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.set('stuff', 'c0')
map1.set('stuff', 'c1')
map1.set('stuff', 'c2')
map2.set('stuff', 'c3')
testConnector.flushAllMessages()
map0.set('stuff', 'deleteme')
map1.set('stuff', 'c1')
map2.set('stuff', 'c2')
map3.set('stuff', 'c3')
map3.delete('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.getMap('map')
t.assert(u.get('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testObserveDeepProperties = tc => {
const { testConnector, users, map1, map2, map3 } = init(tc, { users: 4 })
const _map1 = map1.set('map', new Y.Map())
let calls = 0
let dmapid
map1.observeDeep(events => {
events.forEach(event => {
calls++
// @ts-ignore
t.assert(event.keysChanged.has('deepmap'))
t.assert(event.path.length === 1)
t.assert(event.path[0] === 'map')
// @ts-ignore
dmapid = event.target.get('deepmap')._item.id
})
})
testConnector.flushAllMessages()
const _map3 = map3.get('map')
_map3.set('deepmap', new Y.Map())
testConnector.flushAllMessages()
const _map2 = map2.get('map')
_map2.set('deepmap', new Y.Map())
testConnector.flushAllMessages()
const dmap1 = _map1.get('deepmap')
const dmap2 = _map2.get('deepmap')
const dmap3 = _map3.get('deepmap')
t.assert(calls > 0)
t.assert(compareIDs(dmap1._item.id, dmap2._item.id))
t.assert(compareIDs(dmap1._item.id, dmap3._item.id))
// @ts-ignore we want the possibility of dmapid being undefined
t.assert(compareIDs(dmap1._item.id, dmapid))
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testObserversUsingObservedeep = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Array<Array<string|number>>}
*/
const paths = []
let calls = 0
map0.observeDeep(events => {
events.forEach(event => {
paths.push(event.path)
})
calls++
})
map0.set('map', new Y.Map())
map0.get('map').set('array', new Y.Array())
map0.get('map').get('array').insert(0, ['content'])
t.assert(calls === 3)
t.compare(paths, [[], ['map'], ['map', 'array']])
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testPathsOfSiblingEvents = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Array<Array<string|number>>}
*/
const paths = []
let calls = 0
const doc = users[0]
map0.set('map', new Y.Map())
map0.get('map').set('text1', new Y.Text('initial'))
map0.observeDeep(events => {
events.forEach(event => {
paths.push(event.path)
})
calls++
})
doc.transact(() => {
map0.get('map').get('text1').insert(0, 'post-')
map0.get('map').set('text2', new Y.Text('new'))
})
t.assert(calls === 1)
t.compare(paths, [['map'], ['map', 'text1']])
compare(users)
}
// TODO: Test events in Y.Map
/**
* @param {Object<string,any>} is
* @param {Object<string,any>} should
*/
const compareEvent = (is, should) => {
for (const key in should) {
t.compare(should[key], is[key])
}
}
/**
* @param {t.TestCase} tc
*/
export const testThrowsAddAndUpdateAndDeleteEvents = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e // just put it on event, should be thrown synchronously anyway
})
map0.set('stuff', 4)
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in contents
map0.set('stuff', new Y.Array())
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in opContents
map0.set('stuff', 5)
// delete
map0.delete('stuff')
compareEvent(event, {
keysChanged: new Set(['stuff']),
target: map0
})
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testThrowsDeleteEventsOnClear = tc => {
const { users, map0 } = init(tc, { users: 2 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e // just put it on event, should be thrown synchronously anyway
})
// set values
map0.set('stuff', 4)
map0.set('otherstuff', new Y.Array())
// clear
map0.clear()
compareEvent(event, {
keysChanged: new Set(['stuff', 'otherstuff']),
target: map0
})
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
*/
export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
const doc = new Y.Doc()
const map = doc.getMap('map')
let updateCalled = false
let throwingObserverCalled = false
let throwingDeepObserverCalled = false
doc.on('update', () => {
updateCalled = true
})
const throwingObserver = () => {
throwingObserverCalled = true
throw new Error('Failure')
}
const throwingDeepObserver = () => {
throwingDeepObserverCalled = true
throw new Error('Failure')
}
map.observe(throwingObserver)
map.observeDeep(throwingDeepObserver)
t.fails(() => {
map.set('y', '2')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
// check if it works again
updateCalled = false
throwingObserverCalled = false
throwingDeepObserverCalled = false
t.fails(() => {
map.set('z', '3')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
t.assert(map.get('z') === '3')
}
/**
* @param {t.TestCase} tc
*/
export const testYmapEventHasCorrectValueWhenSettingAPrimitive = tc => {
const { users, map0 } = init(tc, { users: 3 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e
})
map0.set('stuff', 2)
t.compare(event.value, event.target.get(event.name))
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapEventHasCorrectValueWhenSettingAPrimitiveFromOtherUser = tc => {
const { users, map0, map1, testConnector } = init(tc, { users: 3 })
/**
* @type {Object<string,any>}
*/
let event = {}
map0.observe(e => {
event = e
})
map1.set('stuff', 2)
testConnector.flushAllMessages()
t.compare(event.value, event.target.get(event.name))
compare(users)
}
/**
* @type {Array<function(Doc,prng.PRNG):void>}
*/
const mapTransactions = [
function set (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
const value = prng.utf16String(gen)
user.getMap('map').set(key, value)
},
function setType (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
const type = prng.oneOf(gen, [new Y.Array(), new Y.Map()])
user.getMap('map').set(key, type)
if (type instanceof Y.Array) {
type.insert(0, [1, 2, 3, 4])
} else {
type.set('deepkey', 'deepvalue')
}
},
function _delete (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
user.getMap('map').delete(key)
}
]
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests10 = tc => {
applyRandomTests(tc, mapTransactions, 3)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests40 = tc => {
applyRandomTests(tc, mapTransactions, 40)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests42 = tc => {
applyRandomTests(tc, mapTransactions, 42)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests43 = tc => {
applyRandomTests(tc, mapTransactions, 43)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests44 = tc => {
applyRandomTests(tc, mapTransactions, 44)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests45 = tc => {
applyRandomTests(tc, mapTransactions, 45)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests46 = tc => {
applyRandomTests(tc, mapTransactions, 46)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests300 = tc => {
applyRandomTests(tc, mapTransactions, 300)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests400 = tc => {
applyRandomTests(tc, mapTransactions, 400)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests500 = tc => {
applyRandomTests(tc, mapTransactions, 500)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests600 = tc => {
applyRandomTests(tc, mapTransactions, 600)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests1000 = tc => {
applyRandomTests(tc, mapTransactions, 1000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests1800 = tc => {
applyRandomTests(tc, mapTransactions, 1800)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests5000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 5000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests10000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 10000)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatGeneratingYmapTests100000 = tc => {
t.skip(!t.production)
applyRandomTests(tc, mapTransactions, 100000)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,223 +0,0 @@
import { init, compare } from './testHelper.js'
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
export const testCustomTypings = () => {
const ydoc = new Y.Doc()
const ymap = ydoc.getMap()
/**
* @type {Y.XmlElement<{ num: number, str: string, [k:string]: object|number|string }>}
*/
const yxml = ymap.set('yxml', new Y.XmlElement('test'))
/**
* @type {number|undefined}
*/
const num = yxml.getAttribute('num')
/**
* @type {string|undefined}
*/
const str = yxml.getAttribute('str')
/**
* @type {object|number|string|undefined}
*/
const dtrn = yxml.getAttribute('dtrn')
const attrs = yxml.getAttributes()
/**
* @type {object|number|string|undefined}
*/
const any = attrs.shouldBeAny
console.log({ num, str, dtrn, attrs, any })
}
/**
* @param {t.TestCase} tc
*/
export const testSetProperty = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
xml0.setAttribute('height', '10')
t.assert(xml0.getAttribute('height') === '10', 'Simple set+get works')
testConnector.flushAllMessages()
t.assert(xml1.getAttribute('height') === '10', 'Simple set+get works (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testHasProperty = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
xml0.setAttribute('height', '10')
t.assert(xml0.hasAttribute('height'), 'Simple set+has works')
testConnector.flushAllMessages()
t.assert(xml1.hasAttribute('height'), 'Simple set+has works (remote)')
xml0.removeAttribute('height')
t.assert(!xml0.hasAttribute('height'), 'Simple set+remove+has works')
testConnector.flushAllMessages()
t.assert(!xml1.hasAttribute('height'), 'Simple set+remove+has works (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testEvents = tc => {
const { testConnector, users, xml0, xml1 } = init(tc, { users: 2 })
/**
* @type {any}
*/
let event
/**
* @type {any}
*/
let remoteEvent
xml0.observe(e => {
event = e
})
xml1.observe(e => {
remoteEvent = e
})
xml0.setAttribute('key', 'value')
t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key')
testConnector.flushAllMessages()
t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on updated key (remote)')
// check attributeRemoved
xml0.removeAttribute('key')
t.assert(event.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute')
testConnector.flushAllMessages()
t.assert(remoteEvent.attributesChanged.has('key'), 'YXmlEvent.attributesChanged on removed attribute (remote)')
xml0.insert(0, [new Y.XmlText('some text')])
t.assert(event.childListChanged, 'YXmlEvent.childListChanged on inserted element')
testConnector.flushAllMessages()
t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on inserted element (remote)')
// test childRemoved
xml0.delete(0)
t.assert(event.childListChanged, 'YXmlEvent.childListChanged on deleted element')
testConnector.flushAllMessages()
t.assert(remoteEvent.childListChanged, 'YXmlEvent.childListChanged on deleted element (remote)')
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testTreewalker = tc => {
const { users, xml0 } = init(tc, { users: 3 })
const paragraph1 = new Y.XmlElement('p')
const paragraph2 = new Y.XmlElement('p')
const text1 = new Y.XmlText('init')
const text2 = new Y.XmlText('text')
paragraph1.insert(0, [text1, text2])
xml0.insert(0, [paragraph1, paragraph2, new Y.XmlElement('img')])
const allParagraphs = xml0.querySelectorAll('p')
t.assert(allParagraphs.length === 2, 'found exactly two paragraphs')
t.assert(allParagraphs[0] === paragraph1, 'querySelectorAll found paragraph1')
t.assert(allParagraphs[1] === paragraph2, 'querySelectorAll found paragraph2')
t.assert(xml0.querySelector('p') === paragraph1, 'querySelector found paragraph1')
compare(users)
}
/**
* @param {t.TestCase} _tc
*/
export const testYtextAttributes = _tc => {
const ydoc = new Y.Doc()
const ytext = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
ytext.observe(event => {
t.compare(event.changes.keys.get('test'), { action: 'add', oldValue: undefined })
})
ytext.setAttribute('test', 42)
t.compare(ytext.getAttribute('test'), 42)
t.compare(ytext.getAttributes(), { test: 42 })
}
/**
* @param {t.TestCase} _tc
*/
export const testSiblings = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
const second = new Y.XmlElement('p')
yxml.insert(0, [first, second])
t.assert(first.nextSibling === second)
t.assert(second.prevSibling === first)
t.assert(first.parent === yxml)
t.assert(yxml.parent === null)
t.assert(yxml.firstChild === first)
}
/**
* @param {t.TestCase} _tc
*/
export const testInsertafter = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText()
const second = new Y.XmlElement('p')
const third = new Y.XmlElement('p')
const deepsecond1 = new Y.XmlElement('span')
const deepsecond2 = new Y.XmlText()
second.insertAfter(null, [deepsecond1])
second.insertAfter(deepsecond1, [deepsecond2])
yxml.insertAfter(null, [first, second])
yxml.insertAfter(second, [third])
t.assert(yxml.length === 3)
t.assert(second.get(0) === deepsecond1)
t.assert(second.get(1) === deepsecond2)
t.compareArrays(yxml.toArray(), [first, second, third])
t.fails(() => {
const el = new Y.XmlElement('p')
el.insertAfter(deepsecond1, [new Y.XmlText()])
})
}
/**
* @param {t.TestCase} _tc
*/
export const testClone = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.getXmlFragment()
const first = new Y.XmlText('text')
const second = new Y.XmlElement('p')
const third = new Y.XmlElement('p')
yxml.push([first, second, third])
t.compareArrays(yxml.toArray(), [first, second, third])
const cloneYxml = yxml.clone()
ydoc.getArray('copyarr').insert(0, [cloneYxml])
t.assert(cloneYxml.length === 3)
t.compare(cloneYxml.toJSON(), yxml.toJSON())
}
/**
* @param {t.TestCase} _tc
*/
export const testFormattingBug = _tc => {
const ydoc = new Y.Doc()
const yxml = /** @type {Y.XmlText} */ (ydoc.get('', Y.XmlText))
const delta = [
{ insert: 'A', attributes: { em: {}, strong: {} } },
{ insert: 'B', attributes: { em: {} } },
{ insert: 'C', attributes: { em: {}, strong: {} } }
]
yxml.applyDelta(delta)
t.compare(yxml.toDelta(), delta)
}
/**
* @param {t.TestCase} _tc
*/
export const testElement = _tc => {
const ydoc = new Y.Doc()
const yxmlel = ydoc.getXmlElement()
const text1 = new Y.XmlText('text1')
const text2 = new Y.XmlText('text2')
yxmlel.insert(0, [text1, text2])
t.compareArrays(yxmlel.toArray(), [text1, text2])
}

View File

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "dom"],
"module": "node16",
"allowJs": true,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"baseUrl": "./",
"emitDeclarationOnly": true,
"strict": true,
"noImplicitAny": true,
"moduleResolution": "nodenext",
"paths": {
"yjs": ["./src/index.js"]
}
},
"include": ["./src/**/*.js", "./tests/**/*.js"]
}