snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15)
Some checks failed
Node.js CI / build (26.x) (push) Successful in 2m53s
Scorecard supply-chain security / Scorecard analysis (push) Failing after 1m5s

This commit is contained in:
upstream-mirror
2026-09-15 17:21:43 +05:00
commit 1ff79b5b83
75 changed files with 32547 additions and 0 deletions

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: dmonad
open_collective: y-collective

90
.github/INCIDENT_RESPONSE.md vendored Normal file
View File

@@ -0,0 +1,90 @@
# Incident Response Plan
How Yjs maintainers handle security incidents. This plan is designed for small teams.
---
## 1. Receive a Private Disclosure
All security issues should be reported privately via the method described in [`SECURITY.md`](../SECURITY.md) (GitHub Security Advisory). Lower-severity issues can be filed as regular issues.
**Target:** Acknowledge receipt within 3 business days.
---
## 2. Triage
Assess severity:
| Severity | Example | Target |
|----------|---------|--------|
| **Critical** | Arbitrary code execution, compromised release | Acknowledge < 24h, fix < 7d |
| **High** | Data leakage, denial of service on shared docs | Acknowledge < 3d, fix < 14d |
| **Medium** | Edge-case corruption under unusual conditions | Fix in next release |
| **Low** | Informational, no direct user risk | Fix as scheduled |
These are good-faith targets for a volunteer-maintained project, not SLAs.
---
## 3. Contain & Fix
1. **Open a private GitHub Security Advisory** for the issue if one doesn't already exist.
2. **Develop the fix** in the advisory's private fork to avoid leaking details.
3. **Assign a CVE** through GitHub's advisory flow before publishing.
4. **Yank affected releases from npm** (`npm deprecate` with a message pointing to the advisory) if the severity is Critical or High.
5. **Publish the patched release** to npm.
6. **Update the version support matrix** in `SECURITY.md`.
---
## 4. Disclose
After the fix is published:
1. **Publish the GitHub Security Advisory** with:
- What happened
- Which versions are affected
- How users can update or mitigate
- The assigned CVE
2. **Notify sponsors** via the appropriate channel (email, GitHub Sponsors update, or OpenCollective post).
3. **Post to GitHub Discussions** for broader community awareness, linking to the advisory.
Responsible disclosure principles:
- Never publish details before a fix is available.
- Never name reporters without their consent.
- Coordinate embargo periods with downstream consumers (e.g., major framework integrations) when needed.
---
## 5. Post-Incident
Within one week of resolution:
- Note what happened, what worked, and what to improve (can be a short section in the advisory itself or a comment on the discussion post).
- Update this plan if the process broke down anywhere.
---
## 6. Checklist (quick reference)
```
[ ] Private report received & acknowledged
[ ] Severity assessed
[ ] Private advisory + fork created on GitHub
[ ] Fix developed & tested
[ ] CVE assigned
[ ] Affected npm versions deprecated (if Critical/High)
[ ] Patched release published
[ ] SECURITY.md version matrix updated
[ ] Advisory published
[ ] Sponsors notified
[ ] Community notified (Discussions)
[ ] Post-incident notes written
```
---
## Plan Maintenance
Review this plan once a year or after any incident. Changes are tracked in git history.

34
.github/workflows/node.js.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
# 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 ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [26.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm run test
env:
CI: true

70
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Publish to npm
on:
workflow_run:
workflows: ["Node.js CI"]
types: [completed]
branches: [main]
jobs:
check:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_publish: ${{ steps.version-check.outputs.should_publish }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 26.x
- name: Check if version is already published
id: version-check
run: |
NAME=$(node -p "require('./package.json').name")
LOCAL=$(node -p "require('./package.json').version")
if npm view "${NAME}@${LOCAL}" version 2>/dev/null; then
echo "should_publish=false" >> "$GITHUB_OUTPUT"
echo "Version $LOCAL already published, skipping."
else
echo "should_publish=true" >> "$GITHUB_OUTPUT"
echo "New version detected: $LOCAL"
fi
publish:
needs: check
if: needs.check.outputs.should_publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 26.x
- run: npm ci
- name: Build
run: npm run dist
- name: Determine npm tag
id: npm-tag
run: |
VERSION=$(node -p "require('./package.json').version")
if [[ "$VERSION" == *-* ]]; then
echo "tag=beta" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
- name: Publish
run: npm publish --provenance --access public --tag ${{ steps.npm-tag.outputs.tag }}
- name: Create GitHub Release
run: |
VERSION=$(node -p "require('./package.json').version")
PRERELEASE=""
if [[ "$VERSION" == *-* ]]; then
PRERELEASE="--prerelease"
fi
gh release create "v${VERSION}" --generate-notes $PRERELEASE
env:
GH_TOKEN: ${{ github.token }}

78
.github/workflows/scorecard.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '28 5 * * 0'
push:
branches: [ "main" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
# file_mode: git
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.vscode
docs
next.md

52
.jsdoc.json Normal file
View File

@@ -0,0 +1,52 @@
{
"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"
}
}

4
.markdownlint.json Normal file
View File

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

View File

@@ -0,0 +1 @@
https://yjs.dev

5
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,5 @@
# Contributor Covenant Code of Conduct
This project follows the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
By participating, you agree to uphold this code. Report unacceptable behavior to [kevin.jahns@protonmail.com](mailto:kevin.jahns@protonmail.com).

9
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,9 @@
# Contributing
1. **Open an issue first.** Discuss your idea or bug report before writing code.
2. **Submit a PR** that references the issue.
3. **Include tests.** Every PR must include tests covering the change. Test-only PRs are welcome too.
Yjs is free to use, but not free to maintain. Companies that rely on Yjs are expected to support the project — through [sponsorship](https://github.com/sponsors/dmonad), contributions, or both — if they want issues, bug reports, and PRs addressed.
Every contribution becomes maintenance work for the project maintainers. Priority is given to features that serve the project's financial supporters. Other contributions may not be accepted solely for this reason.

179
INTERNALS.md Normal file
View File

@@ -0,0 +1,179 @@
# 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.

23
LICENSE Normal file
View File

@@ -0,0 +1,23 @@
The MIT License (MIT)
Copyright (c) 2023
- Kevin Jahns <kevin.jahns@protonmail.com>.
- 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
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1445
README.md Normal file

File diff suppressed because it is too large Load Diff

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 13.6.x | :white_check_mark: |
| < 13.6.0 | :x: |
## Reporting a Vulnerability
If the vulnerability could be used to exploit user data or leak other sensitive information, please report it privately via [GitHub's security advisory reporting](https://github.com/yjs/yjs/security/advisories/new). For lower-severity issues, feel free to open a regular issue.

131
THREAT_MODEL.md Normal file
View File

@@ -0,0 +1,131 @@
# Threat Model - Yjs
This document outlines the threat model for the Yjs project, a CRDT framework for shared editing. It identifies assets, threat actors, attack surfaces, and mitigations.
## 1. Assets
What we are protecting:
| Asset | Description |
|-------|-------------|
| Document data | Shared documents edited by end users via Yjs-powered applications |
| Library integrity | The correctness and safety of the Yjs source code and published npm packages |
| CI/CD pipeline | GitHub Actions workflows, publishing credentials, and automation |
| Maintainer accounts | GitHub and npm accounts of project maintainers |
| User trust | Reputation and reliability of the project as a dependency |
## 2. Threat Actors
| Actor | Motivation | Capability |
|-------|-----------|------------|
| Malicious contributor | Inject backdoor or vulnerable code via pull requests | Low-Medium: constrained by code review |
| Supply chain attacker | Compromise a dependency to reach Yjs consumers downstream | Medium: targets transitive dependencies |
| Malicious peer (network) | Exploit the CRDT sync protocol to corrupt documents or crash peers | Medium: can craft arbitrary binary messages |
| Compromised maintainer account | Publish a malicious package version to npm | High: full publish access |
| Automated bots | Spam, typosquatting, or social engineering via issues/PRs | Low |
## 3. Attack Surfaces
### 3.1 Document Corruption by Write-Peers
Any peer with write access has the theoretical ability to corrupt a Yjs document. The CRDT protocol is designed for collaboration between trusted peers — it guarantees conflict-free merging, but it does not protect against a malicious peer intentionally writing destructive operations. Once a malicious update is applied to a document, it becomes part of the document's permanent history.
**It is therefore critical that untrusted peers are never allowed to write updates to the original Yjs document.**
**Server-side filtering of updates is a common antipattern.** Some applications attempt to validate or filter incoming updates on the server before applying them to the shared document. This approach is fundamentally flawed with untrusted peers — Yjs updates are binary-encoded CRDT operations, and selectively filtering them can easily be exploited and is not a reliable security boundary.
**Suggestion-only (untrusted) users should work on a separate Yjs document fork**, not on the original document. Their changes can be reviewed and selectively merged by a trusted peer. Only trusted, authenticated peers should be allowed to write to the authoritative document.
Additionally, a malicious peer could send crafted binary messages to:
- **Cause denial of service** via extremely large updates, deeply nested structures, or messages that trigger excessive computation.
- **Trigger buffer overflows or out-of-bounds reads** in the binary decoder.
**Mitigations:**
- Never allow untrusted peers to write to the original document.
- Use separate document forks for suggestion-only users.
- Do not rely on server-side update filtering as a security measure.
- Authenticate and authorize peers before granting write access.
- Validate incoming binary messages and reject malformed data.
- Implement size limits on incoming updates where possible.
- Fuzz test the decoder and update application paths.
### 3.2 Supply Chain (Dependencies & npm Publishing)
Yjs is a widely-used library. Compromising it would impact many downstream projects.
**Yjs only depends on packages that are maintained by the Yjs maintainers themselves** (such as `lib0` and `@y/protocols`). This significantly reduces supply chain risk, as there are no third-party runtime dependencies that could be independently compromised.
- **Compromised npm publish token** leading to a malicious release.
- **Typosquatting** of the package name.
However, the project does use third-party **dev dependencies** (linters, test tools, build tools, etc.). A compromised dev dependency could execute malicious code on a maintainer's machine during development, potentially leading to credential theft or tampered releases. It is important to only install trusted dev dependencies and to review them carefully.
**Mitigations:**
- All runtime dependencies are maintained by the Yjs team, eliminating third-party supply chain risk.
- Only install trusted, well-maintained dev dependencies and review new additions carefully.
- Enable MFA on all maintainer npm and GitHub accounts.
- Use `npm provenance` or signed releases where supported.
- Pin dependency versions and review lockfile changes in PRs.
- Monitor for typosquat packages.
### 3.3 Pull Requests & Code Contributions
- **Malicious code** submitted via pull requests (obfuscated backdoors, subtle logic changes).
- **CI exploitation** through crafted PR workflows that exfiltrate secrets.
**Mitigations:**
- Require at least one maintainer review before merging.
- Do not run CI with secrets on PRs from forks.
- Use branch protection rules on `main`.
- Review changes carefully, especially to binary encoding/decoding, crypto, and publishing scripts.
### 3.4 GitHub Actions & CI/CD
- **Secret exfiltration** from workflow runs.
- **Workflow injection** via untrusted input in PR titles, branch names, or commit messages.
**Mitigations:**
- Follow the principle of least privilege for workflow permissions.
- Avoid using `pull_request_target` with checkout of PR code.
- Pin action versions to specific commit SHAs.
- Limit secret access to required workflows only.
### 3.5 Maintainer Account Compromise
- **Account takeover** of a GitHub or npm maintainer account.
- Used to merge malicious PRs, publish backdoored packages, or modify CI.
**Mitigations:**
- Require MFA for all maintainers (GitHub and npm).
- Limit the number of accounts with publish access.
- Periodically review organization members and their permissions.
## 4. Risk Assessment
| Threat | Likelihood | Impact | Priority |
|--------|-----------|--------|----------|
| Malicious CRDT update from peer | Medium | High (data corruption) | High |
| Compromised npm publish | Low | Critical (supply chain) | High |
| Malicious pull request merged | Low | High | Medium |
| Dependency compromise | Low | High | Medium |
| CI secret exfiltration | Low | Medium | Medium |
| Maintainer account takeover | Low | Critical | High |
## 5. Out of Scope
The following are the responsibility of the application developer using Yjs, not the Yjs library itself:
- **Authentication and authorization** of peers connecting to a collaboration session.
- **Transport-layer security** (TLS/WSS) for data in transit.
- **Server-side access control** for who can read or write documents.
- **Application-level input validation** (e.g., sanitizing rich text content for XSS).
## 6. Review Schedule
This threat model should be reviewed:
- At least once per year.
- When a new major version is released.
- When a security incident occurs.
- When significant new features or dependencies are added.

130
attributing-content.md Normal file
View File

@@ -0,0 +1,130 @@
# IdSets and IdMaps
`IdSet` is a data structure (formerly `DeleteSet`) that allows us to efficiently
represent ranges of ids in Yjs (all content is identifyable by ids).
`IdMap` is a new data structure that allows us to efficiently map ids to
attributes. It can be efficiently encoded.
We can perform all usual set operations on `IdMap`s and `IdSet`s: diff, merge,
intersect.
# Attribution of content
In order to implement a Google Docs-like versioning feature, we want to be able
to attribute content with additional information (who created the change,
when was this change created, ..).
When we click on a version in Google Docs, we might get annotated changes like
this:
```
# E.g. If Bob appends "world" to the previous version "hello "
[{ insert: 'hello' }, { insert: 'world', color: 'blue', creator: 'Bob', when: 'yesterday' }]
# E.g. If Bob deletes "world" from the previous version "hello world"
[{ insert: 'hello' }, { insert: 'world', backgroundColor: 'red', creator: 'Bob', when: 'yesterday' }]
```
In Yjs, we can now "attribute" changes with additional information. When we
render content using methods like `toString()` or `toDelta()`, Yjs will render
the unattributed content as-is, but it will render the attributed content with
the additional information. As all changes in Yjs are identifyable by Ids, we
can use `IdMap`s to map changes to "attributions". For example, we could
attribute deletions and insertions of a change and render them:
```js
// We create some initial content "Hello World!". Then we create another
// document that will have a bunch of changes (make "Hell" italic, replace "World"
// with "attributions").
const ydocVersion0 = new Y.Doc({ gc: false })
ydocVersion0.get().insert(0, 'Hello World!')
const ydoc = new Y.Doc({ gc: false })
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocVersion0))
const ytext = ydoc.get()
ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert('attributions').done())
// this represents all insertions of ydoc
const insertionSet = Y.createInsertSetFromStructStore(ydoc.store, false)
const deleteSet = Y.createDeleteSetFromStructStore(ydoc.store)
// exclude the changes from `ydocVersion0`
const insertionSetDiff = Y.diffIdSet(insertionSet, Y.createInsertSetFromStructStore(ydocVersion0.store, false))
const deleteSetDiff = Y.diffIdSet(deleteSet, Y.createDeleteSetFromStructStore(ydocVersion0.store))
// assign attributes to the diff
const attributedInsertions = Y.createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute('insert', 'Bob')])
const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('delete', 'Bob')])
// now we can define a renderer that maps these changes to output. One of the
// implementations is the TwosetRenderer
const renderer = new Y.TwosetRenderer(attributedInsertions, attributedDeletions)
// we render the attributed content with the renderer
const attributedContent = ytext.toDelta({ renderer })
console.log(JSON.stringify(attributedContent.toJSON(), null, 2))
const expectedContent = delta.create().insert('Hell', { italic: true }, { format: { italic: ['Bob'] } }).insert('o ').insert('World', {}, { delete: ['Bob'] }).insert('attributions', {}, { insert: ['Bob'] }).insert('!')
t.assert(attributedContent.equals(expectedContent))
// this is how the output would look like
const output = {
"type": "delta",
"children": [
{
"type": "insert",
"insert": "Hell",
"format": {
"italic": true
},
"attribution": { // no "insert" attribution: the insertion "Hell" is not attributed to anyone
"format": {
"italic": [ // the formatting attribute "italic" was added by Bob
"Bob"
]
}
}
},
{
"type": "insert",
"insert": "o " // the insertion "o " has no attributions
},
{
"type": "insert",
"insert": "World",
"attribution": { // the insertion "World" was deleted by Bob
"delete": [
"Bob"
]
}
},
{
"type": "insert",
"insert": "attributions", // the insertion "attributions" was inserted by Bob
"attribution": {
"insert": [
"Bob"
]
}
},
{
"type": "insert",
"insert": "!" // the insertion "!" has no attributions
}
]
}
```
We get a similar output to Google Docs: Insertions, Deletions, and changes to
formatting (attributes) are clearly associated to users. It will be the job of
the editor to render those changes with background-color etc..
Of course, we could associated changes also to multiple users like this:
```js
const attributedDeletions = Y.createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('insert', 'Bob'), Y.createContentAttribute('insert', 'OpenAI o3')])
```
You could use the same output to calculate a real diff as well (consisting of
deletions and insertions only, without Attributions).
`AbstractRenderer` is the abstract base class for renderers, which map
attributions onto content. It is possible to highlight arbitrary content with
this approach.
The attribution data is encoded very efficiently. The ids are encoded using
run-length encoding and the Attributes are de-duplicated and only encoded once.
The above example encodes in 27 bytes.

148
funding.json Normal file
View File

@@ -0,0 +1,148 @@
{
"version": "v1.0.0",
"entity": {
"type": "group",
"role": "steward",
"name": "Kevin Jahns",
"email": "kevin.jahns@protonmail.com",
"phone": "",
"description": "Independent OSS Developer maintaining Yjs and many related libraries. My goal is to make the web more (real-time) collaborative.",
"webpageUrl": {
"url": "https://yjs.dev",
"wellKnown": "https://yjs.dev/.well-known/funding-manifest-urls"
}
},
"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://yjs.dev",
"wellKnown": "https://yjs.dev/.well-known/funding-manifest-urls"
},
"repositoryUrl": {
"url": "https://github.com/yjs/yjs"
},
"licenses": [
"spdx:MIT"
],
"tags": [
"collaboration",
"p2p",
"CRDT",
"rich-text",
"real-time"
]
},
{
"guid": "titanic",
"name": "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": "https://github.com/sponsors/dmonad",
"description": "For funding of the Yjs project"
},
{
"guid": "yjs-opencollective",
"type": "payment-provider",
"address": "https://opencollective.com/y-collective/projects/yjs",
"description": "For funding Yjs via the OpenCollective."
}
],
"plans": [
{
"guid": "supporter",
"status": "active",
"name": "Supporter",
"description": "",
"amount": 0,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors",
"yjs-opencollective"
]
},
{
"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",
"yjs-opencollective"
]
},
{
"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",
"yjs-opencollective"
]
},
{
"guid": "silver-sponsor",
"status": "active",
"name": "Silver Sponsor",
"description": "This is the recommended plan for large/successful companies that use Yjs.",
"amount": 1000,
"currency": "USD",
"frequency": "monthly",
"channels": [
"github-sponsors",
"yjs-opencollective"
]
},
{
"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",
"yjs-opencollective"
]
}
],
"history": null
}
}

53
global.d.ts vendored Normal file
View File

@@ -0,0 +1,53 @@
declare type GC = import('./src/structs/GC.js').GC
declare type Item = import('./src/structs/Item.js').Item
declare type Skip = import('./src/structs/Skip.js').Skip
declare type IdRange = import('./src/utils/ids.js').IdRange
declare type IdSet = import('./src/utils/ids.js').IdSet
declare type IdMap<Attrs> = import('./src/utils/ids.js').IdMap<Attrs>
declare type AttrRanges<Attrs = any> = import('./src/utils/ids.js').AttrRanges<Attrs>
declare type AttrRange<Attrs = any> = import('./src/utils/ids.js').AttrRange<Attrs>
declare type ContentAttribute<V=any> = import('./src/utils/ids.js').ContentAttribute<V>
declare type ContentIds = import('./src/utils/ids.js').ContentIds
declare type ContentMap= import('./src/utils/ids.js').ContentMap
declare type BlockSet = import('./src/utils/BlockSet.js').BlockSet
declare type UpdateDecoderV1 = import('./src/utils/UpdateDecoder.js').UpdateDecoderV1
declare type UpdateDecoderV2 = import('./src/utils/UpdateDecoder.js').UpdateDecoderV2
declare type UpdateEncoderV1 = import('./src/utils/UpdateEncoder.js').UpdateEncoderV1
declare type UpdateEncoderV2 = import('./src/utils/UpdateEncoder.js').UpdateEncoderV2
declare type IdSetEncoderV1 = import('./src/utils/UpdateEncoder.js').IdSetEncoderV1
declare type IdSetEncoderV2 = import('./src/utils/UpdateEncoder.js').IdSetEncoderV2
declare type IdSetDecoderV1 = import('./src/utils/UpdateDecoder.js').IdSetDecoderV1
declare type IdSetDecoderV2 = import('./src/utils/UpdateDecoder.js').IdSetDecoderV2
declare type ID = import('./src/utils/ID.js').ID
declare type Transaction = import('./src/utils/Transaction.js').Transaction
declare type StructStore = import('./src/utils/StructStore.js').StructStore
declare type Doc = import('./src/utils/Doc.js').Doc
declare type YNode<DConf extends import('lib0/delta').DeltaConf = any> = import('./src/ynode.js').YNode<DConf>
declare type YEvent<DConf extends import('lib0/delta').DeltaConf> = import('./src/utils/YEvent.js').YEvent<DConf>
declare type EventHandler<ARG1=any,ARG2=any> = import('./src/utils/EventHandler.js').EventHandler<ARG1, ARG2>
declare type AbstractStruct = import('./src/structs/AbstractStruct.js').AbstractStruct
declare type AbstractContent = import('./src/structs/Item.js').AbstractContent
declare type ContentType = import('./src/structs/Item.js').ContentType
declare type ContentAny = import('./src/structs/Item.js').ContentAny
declare type ContentDoc = import('./src/structs/Item.js').ContentDoc
declare type ContentJSON = import('./src/structs/Item.js').ContentJSON
declare type ContentEmbed = import('./src/structs/Item.js').ContentEmbed
declare type ContentFormat = import('./src/structs/Item.js').ContentFormat
declare type ContentDeleted = import('./src/structs/Item.js').ContentDeleted
declare type ContentString = import('./src/structs/Item.js').ContentString
declare type DeltaConf = import('lib0/delta').DeltaConf
declare type Delta<DConf extends DeltaConf> = import('lib0/delta').Delta<DConf>
// @todo the below should have a separate Y/[mod] export
declare type StackItem = import('./src/utils/UndoManager.js').StackItem
declare type UndoManager = import('./src/utils/UndoManager.js').UndoManager
declare type AbstractRenderer = import('./src/utils/renderer-helpers.js').AbstractRenderer
declare type Attribution = import('./src/utils/renderer-helpers.js').Attribution
declare type AttributedContent<T = any> = import('./src/utils/renderer-helpers.js').AttributedContent<T>
declare type Snapshot = import('./src/utils/Snapshot.js').Snapshot

5935
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

90
package.json Normal file
View File

@@ -0,0 +1,90 @@
{
"name": "@y/y",
"version": "14.0.0-rc.26",
"description": "Shared Editing Library",
"type": "module",
"sideEffects": false,
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"scripts": {
"clean": "rm -rf dist",
"test": "NODE_ENV=development node ./tests/index.js --repetition-time 50",
"test-extensive": "node ./tests/index.js --production --repetition-time 10000",
"dist": "npm run clean && PRODUCTION=1 tsc --noEmit false && test -e dist/src/index.d.ts",
"lint": "markdownlint README.md && standard && tsc && npx dpdm --exit-code circular:1 --warning false --tree false src/index.js",
"debug": "npm run gentesthtml && 0serve -o test.html",
"debug:node": "node --inspect-brk tests/index.js",
"trace-deopt": "clear && node --trace-deopt ./tests/index.js",
"trace-opt": "clear && node --trace-opt ./tests/index.js",
"gentesthtml": "0gentesthtml --script ./tests/index.js --include-dependencies @y/protocols > test.html"
},
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"default": "./src/index.js"
},
"./meta": {
"types": "./dist/src/utils/meta.d.ts",
"default": "./src/utils/meta.js"
},
"./testHelper": {
"types": "./dist/tests/testHelper.d.ts",
"default": "./tests/testHelper.js"
},
"./package.json": "./package.json"
},
"files": [
"src",
"dist/src",
"tests/testHelper.js",
"global.d.ts"
],
"dictionaries": {
"test": "tests"
},
"standard": {
"ignore": [
"/dist",
"/node_modules"
]
},
"repository": {
"type": "git",
"url": "https://github.com/yjs/yjs.git"
},
"keywords": [
"Yjs",
"CRDT",
"offline",
"offline-first",
"shared-editing",
"concurrency",
"collaboration"
],
"author": "Kevin Jahns",
"email": "kevin.jahns@protonmail.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/yjs/yjs/issues"
},
"homepage": "https://docs.yjs.dev",
"dependencies": {
"lib0": "^1.0.0-rc.29"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@y/protocols": "^1.0.6-rc.1",
"@y/y": ".",
"dpdm": "^4.0.1",
"markdownlint": "^0.41.1",
"markdownlint-cli": "^0.49.1",
"standard": "^17.1.2",
"typescript": "^5.9.3"
},
"engines": {
"npm": ">=8.0.0",
"node": ">=22.0.0"
}
}

234
renderer.md Normal file
View File

@@ -0,0 +1,234 @@
# Attribution Feature
The Attribution feature extends Yjs types to provide rich metadata about content
changes, including information about who created, deleted, or formatted content.
This enables powerful collaborative editing features such as authorship tracking
and change visualization. The information about who performed which changes can
be handled by a separate CRDT (which is part of the renderer).
## Core Concepts
### Renderer
A `renderer` renders Content (with its Attributions) to a delta. It is the
central component for the attribution feature: pass it to methods like
`toDelta()` / `getDelta()` to render content together with attribution metadata.
Different implementations of Renderer are available for different use cases:
- `DiffRenderer`: Highlights the differences between two Yjs documents
- `SnapshotRenderer`: Highlights the differences between two snapshots
### Attributed Content
Attributed content includes standard Yjs operations enhanced with attribution metadata:
```javascript
// Standard content
[{ insert: 'hello world' }]
// Attributed content
[
{ insert: 'hello', attribution: { insert: ['kevin'] } },
{ insert: ' world', attribution: { insert: ['alice'] } }
]
```
### Delete Attribution
Deleted content is represented in attributed results to maintain authorship information and proper position tracking:
```javascript
// Shows deleted content with attribution
[
{ insert: 'hello ', attribution: { delete: ['kevin'] } },
{ insert: 'world' }
]
```
## API Reference
### Y.Node
In Yjs v14 all shared types (text, array, map, xml) are instances of the unified
`Y.Node`.
#### `toDelta([{ renderer }])`
Returns the delta representation (lib0/delta) of the type's content, optionally
with attribution information.
**Parameters:**
- `renderer` (optional): The renderer instance
**Returns:**
- A `Delta` describing the content, with attribution metadata if `renderer` is provided
**Examples:**
```javascript
const ytext = ydoc.get()
// Content is inserted during collaborative editing
// Attribution is handled automatically by the server
// Without attribution
const d = ytext.toDelta()
// [{ insert: 'hello world' }]
// With attribution
const attributedDelta = ytext.toDelta({ renderer })
// [
// { insert: 'hello', attribution: { insert: ['kevin'] } },
// { insert: ' world', attribution: { insert: ['alice'] } }
// ]
```
#### `applyDelta(delta, [origin], [{ renderer }])`
Applies a delta (lib0/delta) on the shared type. The optional `origin` is stored
on the transaction (`transaction.origin`) and forwarded verbatim on the emitted
`'delta'` event (lib0 RDT spec), so listeners can recognize — and skip — changes
they produced themselves. When a `renderer` is provided, positions in the delta
are interpreted relative to the attributed (rendered) content.
**Parameters:**
- `delta`: The changes to apply
- `origin` (optional): Origin of the transaction that applies this delta; defaults to `null`
- `renderer` (optional): The renderer instance
### YEvent
#### `getDelta([{ renderer, deep }])`
Returns the changes of an event as a delta, optionally rendered with attribution
information.
**Parameters:**
- `renderer` (optional): The renderer instance
- `deep` (optional): Render child types as deltas
**Returns:**
- A `Delta` describing the changes, with attribution metadata if `renderer` is provided
## Position Adjustments
When working with attributed content, position calculations must account for deleted content that appears in the attributed representation but not in the standard representation.
### Example: Position Adjustment
```javascript
// Standard content (length: 5)
ytext.toString() // "world"
// Attributed content (includes deleted content)
ytext.toDelta({ renderer })
// [
// { insert: 'hello ', attribution: { delete: ['kevin'] } }, // positions 0-5
// { insert: 'world' } // positions 6-10
// ]
// To insert after "world":
// - Standard position: 5 (after "world")
// - Attributed position: 11 (after "world" accounting for deleted "hello ")
```
## Use Cases
Events in Yjs are enhanced to work with attributed content, automatically adjusting positions when attribution is considered.
### Event Position Adjustment
When a `renderer` is used, event positions are automatically adjusted to account for deleted content.
**Example:**
```javascript
// Initial content: "hello world"
// User deletes "hello " (positions 0-6)
// Current visible content: "world"
ytext.observe((event, transaction) => {
// User wants to insert "!" after "world"
// Standard event (without attribution)
const standardDelta = event.getDelta()
// Shows insertion at position 5 (after "world" in visible content)
// Attributed event (with renderer)
const attributedDelta = event.getDelta({ renderer })
// Shows insertion at position 11 (accounting for deleted "hello ")
// [
// { insert: 'hello ', attribution: { delete: ['kevin'] } },
// { insert: 'world' },
// { insert: '!' } // inserted at attributed position 11
// ]
})
```
## Use Cases
### Authorship Visualization
Display content with visual indicators of who created each part:
```javascript
function renderWithAuthorship(ytext, renderer) {
const attributedDelta = ytext.toDelta({ renderer })
return attributedDelta.children.map(op => {
const author = op.attribution?.insert?.[0] || 'unknown'
const isDeleted = op.attribution?.delete
return {
content: op.insert,
author,
isDeleted,
className: `author-${author} ${isDeleted ? 'deleted' : ''}`
}
})
}
```
### Change Tracking
Track who made specific changes to content:
```javascript
function trackChanges(ytext, renderer) {
ytext.observe((event, transaction) => {
const changes = event.getDelta({ renderer })
changes.children.forEach(change => {
if (change.attribution) {
console.log(`Change by ${change.attribution.insert?.[0] || change.attribution.delete?.[0]}:`, change)
}
})
})
}
```
## Best Practices
### Renderer Lifecycle
- Create one renderer per document or collaboration session
- Ensure the renderer is consistently used across all operations
- Pass the same renderer instance to all methods that need attribution
## Migration Guide
### Upgrading Existing Code
To add attribution support to existing Yjs applications:
1. **Add renderer**: Create and configure a renderer
2. **Update method calls**: Add the renderer parameter to relevant method calls
3. **Handle attributed content**: Update code to handle the new attribution metadata format
4. **Adjust position calculations**: Update position calculations to account for deleted content
### Backward Compatibility
The Attribution feature is fully backward compatible:
- All existing methods work without the renderer parameter
- Existing code continues to work unchanged
- Attribution is opt-in and doesn't affect performance when not used

15
rollup.config.js Normal file
View File

@@ -0,0 +1,15 @@
// @todo remove
export default [{
input: {
yjs: './src/index.js',
testHelper: './tests/testHelper.js'
},
output: {
dir: 'dist',
format: 'esm',
entryFileNames: '[name].js',
sourcemap: true
},
external: id => /^(lib0|@y)\//.test(id)
}]

66
src/index.js Normal file
View File

@@ -0,0 +1,66 @@
/// <reference types="../global.d.ts" />
/** eslint-env browser */
// Order matters: follows internals.js ordering to avoid circular dependency issues
export { IdSet, equalIdSets, createDeleteSetFromStructStore, createInsertSetFromStructStore, diffIdSet, createIdSet, mergeIdSets, insertIntoIdSet, iterateStructsByIdSet, gcIdSet, readIdSet, writeIdSet, encodeIdSet, decodeIdSet, IdMap, createIdMap, createContentAttribute, ContentAttribute, diffIdMap, encodeIdMap, createIdMapFromIdSet, createIdSetFromIdMap, insertIntoIdMap, mergeIdMaps, readIdMap, writeIdMap, decodeIdMap, filterIdMap, intersectSets, intersectMaps, $idSet, $idMapAny, $idMap } from './utils/ids.js'
export { Doc } from './utils/Doc.js'
export { $doc } from './utils/schemas.js'
export { UpdateDecoderV1, UpdateDecoderV2 } from './utils/UpdateDecoder.js'
export { UpdateEncoderV1, UpdateEncoderV2 } from './utils/UpdateEncoder.js'
export { applyUpdate, applyUpdateV2, readUpdate, readUpdateV2, encodeStateAsUpdate, encodeStateAsUpdateV2, encodeStateVector, decodeStateVector, diffUpdate, diffUpdateV2, mergeUpdates, mergeUpdatesV2, createDocFromUpdate, createDocFromUpdateV2, cloneDoc } from './utils/encoding.js'
export { ID, createID, compareIDs, findRootTypeKey } from './utils/ID.js'
export { isParentOf } from './utils/isParentOf.js'
export { logNode } from './utils/logging.js'
export { createRelativePositionFromTypeIndex, createRelativePositionFromJSON, createAbsolutePositionFromRelativePosition, compareRelativePositions, AbsolutePosition, RelativePosition, relativePositionToJSON, encodeRelativePosition, decodeRelativePosition } from './utils/RelativePosition.js'
export { Snapshot, createSnapshot, snapshot, emptySnapshot, createDocFromSnapshot, decodeSnapshot, encodeSnapshot, decodeSnapshotV2, encodeSnapshotV2, equalSnapshots, snapshotContainsUpdate } from './utils/Snapshot.js'
export { findIndexSS, getItemCleanStart, getItemCleanEnd, tryGc } from './utils/transaction-helpers.js'
export { Transaction, transact, cleanupYTextFormatting } from './utils/Transaction.js'
export { UndoManager, undoContentIds } from './utils/UndoManager.js'
export { logUpdate, logUpdateV2, decodeUpdate, decodeUpdateV2, encodeStateVectorFromUpdate, encodeStateVectorFromUpdateV2, convertUpdateFormatV1ToV2, convertUpdateFormatV2ToV1, obfuscateUpdate, obfuscateUpdateV2, createContentIdsFromUpdate, createContentIdsFromUpdateV2, intersectUpdateWithContentIds, intersectUpdateWithContentIdsV2 } from './utils/updates.js'
export { YEvent, getPathTo } from './utils/YEvent.js'
export { AttributionsRenderer, createAttributionsRenderer, AbstractRenderer, createDiffRenderer, DiffRenderer, createSnapshotRenderer, SnapshotRenderer, $renderer } from './utils/Renderer.js'
export { diffDocsToDelta } from './utils/delta-helpers.js'
export { createRelativePositionsFromDeltaPositions, createDeltaPositionsFromRelativePositions, createRelativePositionFromDeltaPosition, createDeltaPositionFromRelativePosition } from './utils/position-helpers.js'
export { YNode as Node, getNodeChildren, nodeMapGetSnapshot, nodeMapGetAllSnapshot, $node, $nodeAny } from './ynode.js'
export { AbstractStruct } from './structs/AbstractStruct.js'
export { GC } from './structs/GC.js'
export { Item, ContentBinary, ContentDeleted, ContentDoc, ContentEmbed, ContentFormat, ContentJSON, ContentAny, ContentString, ContentType } from './structs/Item.js'
export { Skip } from './structs/Skip.js'
export * from './utils/meta.js'
/**
* @typedef {import('./utils/ids.js').ContentIds} ContentIds
*/
/**
* @typedef {import('./utils/ids.js').ContentMap} ContentMap
*/
const glo = /** @type {any} */ (typeof globalThis !== 'undefined'
? globalThis
: typeof window !== 'undefined'
? window
// @ts-ignore
: typeof global !== 'undefined' ? global : {})
const importIdentifier = '__ $YJS14$ __'
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

@@ -0,0 +1,64 @@
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 {GC|Item|Skip} _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()
}
/**
* @param {number} _diff
* @return {GC|Item|Skip}
*/
splice (_diff) {
throw error.methodUnimplemented()
}
}
/**
* @param {IdSet} idSet
* @param {AbstractStruct} struct
*
* @private
* @function
*/
export const addStructToIdSet = (idSet, struct) => idSet.add(struct.id.client, struct.id.clock, struct.length)

75
src/structs/GC.js Normal file
View File

@@ -0,0 +1,75 @@
import { AbstractStruct, addStructToIdSet } from './AbstractStruct.js'
import { createID } from '../utils/ID.js'
export const structGCRefNumber = 0
/**
* @private
*/
export class GC extends AbstractStruct {
get deleted () {
return true
}
delete () {}
/**
* @param {GC | Skip | Item} right
* @return {boolean}
*/
mergeWith (right) {
if (this.constructor !== right.constructor) {
return false
}
this.length += right.length
return true
}
/**
* @param {Transaction} transaction
* @param {number} offset - @todo remove offset parameter
*/
integrate (transaction, offset) {
if (offset > 0) {
this.id.clock += offset
this.length -= offset
}
transaction.deleteSet.add(this.id.client, this.id.clock, this.length)
addStructToIdSet(transaction.insertSet, this)
transaction.doc.store.add(this)
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {number} offset
* @param {number} offsetEnd
*/
write (encoder, offset, offsetEnd) {
encoder.writeInfo(structGCRefNumber)
encoder.writeLen(this.length - offset - offsetEnd)
}
/**
* gc structs can't be spliced.
*
* If this feature is required in the future, then need to try to merge this struct after
* transaction.
*
* @param {number} diff
*/
splice (diff) {
const other = new GC(createID(this.id.client, this.id.clock + diff), this.length - diff)
this.length = diff
return other
}
}
/**
* @type {0}
*/
GC.prototype.ref = structGCRefNumber
/**
* @type {false}
*/
GC.prototype.isItem = false

1517
src/structs/Item.js Normal file

File diff suppressed because it is too large Load Diff

72
src/structs/Skip.js Normal file
View File

@@ -0,0 +1,72 @@
import * as encoding from 'lib0/encoding'
import { AbstractStruct } from './AbstractStruct.js'
import { createID } from '../utils/ID.js'
export const structSkipRefNumber = 10
/**
* @private
*/
export class Skip extends AbstractStruct {
get deleted () {
return false
}
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) {
if (offset > 0) {
this.id.clock += offset
this.length -= offset
}
const store = transaction.doc.store
store.skips.add(this.id.client, this.id.clock, this.length)
store.add(this)
}
/**
* @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 {number} diff
*/
splice (diff) {
const other = new Skip(createID(this.id.client, this.id.clock + diff), this.length - diff)
this.length = diff
return other
}
}
/**
* @type {10}
*/
Skip.prototype.ref = structSkipRefNumber
/**
* @type {false}
*/
Skip.prototype.isItem = false

291
src/utils/BlockSet.js Normal file
View File

@@ -0,0 +1,291 @@
import * as decoding from 'lib0/decoding'
import * as binary from 'lib0/binary'
import * as map from 'lib0/map'
import * as array from 'lib0/array'
import * as math from 'lib0/math'
import * as encoding from 'lib0/encoding'
import * as number from 'lib0/number'
import { createID, ID } from './ID.js'
import { Item } from '../structs/Item.js'
import { readItemContent } from '../ynode.js'
import { findIndexCleanStart } from './transaction-helpers.js'
import { Skip } from '../structs/Skip.js'
import { createIdSet, IdRange } from './ids.js'
import { sliceStruct } from './updates.js'
import { GC } from '../structs/GC.js'
import { writeStructs } from './encoding-helpers.js'
/**
* @param {UpdateDecoderV1 | UpdateDecoderV2} decoder The decoder object to read data from.
* @return {BlockSet}
*
* @private
* @function
*/
export const readBlockSet = (decoder) => {
const clientRefs = new BlockSet()
const numOfStateUpdates = decoding.readVarUint(decoder.restDecoder)
for (let i = 0; i < numOfStateUpdates; i++) {
const numberOfBlocks = decoding.readVarUint(decoder.restDecoder)
/**
* @type {Array<GC|Item|Skip>}
*/
const refs = new Array(numberOfBlocks)
const client = decoder.readClient()
let clock = decoding.readVarUint(decoder.restDecoder)
clientRefs.clients.set(client, new BlockRange(refs))
for (let i = 0; i < numberOfBlocks; 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 Block (nothing to apply)
// @todo we could reduce the amount of checks by adding Skip block 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 block = 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() ? decoder.readString() : decoder.readLeftID()) : null, // parent
cantCopyParentInfo && (info & binary.BIT6) === binary.BIT6 ? decoder.readString() : null, // parentSub
readItemContent(decoder, info) // item content
)
refs[i] = block
clock += block.length
}
}
}
}
return clientRefs
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {BlockSet} blocks
*/
export const writeBlockSet = (encoder, blocks) => {
// write # states that were updated
encoding.writeVarUint(encoder.restEncoder, blocks.clients.size)
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
array.from(blocks.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, blockrange]) => {
writeStructs(encoder, blockrange.refs, client, [new IdRange(0, number.MAX_SAFE_INTEGER)])
})
}
class BlockRange {
/**
* @param {Array<Item|GC|Skip>} refs
*/
constructor (refs) {
this.i = 0
/**
* @type {Array<Item | GC | Skip>}
*/
this.refs = refs
}
}
export class BlockSet {
constructor () {
/**
* @type {Map<number, BlockRange>}
*/
this.clients = map.create()
}
toIdSet () {
const inserts = createIdSet()
this.clients.forEach((ranges, clientid) => {
let lastClock = 0
let lastLen = 0
ranges.refs.forEach(block => {
if (block instanceof Skip) return
if (lastClock + lastLen === block.id.clock) {
// default case: extend prev entry
lastLen += block.length
} else {
lastLen > 0 && inserts.add(clientid, lastClock, lastLen)
lastClock = block.id.clock
lastLen = block.length
}
})
inserts.add(clientid, lastClock, lastLen)
})
return inserts
}
/**
* Remove id-ranges from update - convert them to skip if applicable.
*
* @param {IdSet} exclude
*/
exclude (exclude) {
const clientids = this.clients.size < exclude.clients.size ? this.clients.keys() : exclude.clients.keys()
for (const client of clientids) {
const range = exclude.clients.get(client)
const structs = this.clients.get(client)?.refs
if (range == null || structs == null) return
const firstStruct = structs[0]
const lastStruct = structs[structs.length - 1]
const idranges = range.getIds()
for (let i = 0; i < idranges.length; i++) {
const range = idranges[i]
let startIndex = 0
if (range.clock >= lastStruct.id.clock + lastStruct.length) continue
if (range.clock > firstStruct.id.clock) {
startIndex = findIndexCleanStart(null, structs, range.clock)
}
let endIndex = structs.length // must be set here, after structs is modified
if (range.clock + range.len <= firstStruct.id.clock) continue
if (range.clock + range.len < lastStruct.id.clock + lastStruct.length) {
endIndex = findIndexCleanStart(null, structs, range.clock + range.len)
}
if (startIndex < endIndex) {
structs[startIndex] = new Skip(new ID(client, range.clock), range.len)
const d = endIndex - startIndex
if (d > 1) {
structs.splice(startIndex + 1, d - 1)
}
}
}
}
}
/**
* @param {BlockSet} inserts
*/
insertInto (inserts) {
inserts.clients.forEach((newranges, clientid) => {
const ranges = this.clients.get(clientid)
if (ranges == null) {
this.clients.set(clientid, newranges)
} else {
const localIsLeft = ranges.refs[0].id.clock < newranges.refs[0].id.clock
const leftRanges = (localIsLeft ? ranges : newranges).refs
const rightRanges = (localIsLeft ? newranges : ranges).refs
const lastBlockLeft = array.last(leftRanges)
const firstBlockRight = rightRanges[0]
const gapSize = firstBlockRight.id.clock - lastBlockLeft.id.clock - lastBlockLeft.length
if (gapSize >= 0) {
// we can do a simple efficient merge
if (gapSize > 0) {
leftRanges.push(new Skip(new ID(clientid, lastBlockLeft.id.clock + lastBlockLeft.length), gapSize))
}
for (let i = 0; i < rightRanges.length; i++) {
leftRanges.push(rightRanges[i])
}
ranges.refs = leftRanges
} else {
// requires more computation because we need to filter duplicates
/**
* @type {Array<GC|Item|Skip>}
*/
const result = []
let nextExpectedClock = leftRanges[0].id.clock
/**
* @param {Item|GC|Skip} block
*/
const addToResult = block => {
result.push(block)
nextExpectedClock = block.id.clock + block.length
}
let li = 0
let ri = 0
/**
* @type {Item|GC|Skip|undefined}
*/
let lblock = leftRanges[li]
/**
* @type {Item|GC|Skip|undefined}
*/
let rblock = rightRanges[ri]
const applyLeft = () => {
if (lblock === undefined) return
// first try to consume left
// left: filter skips and known ops
while (lblock !== undefined && (lblock.constructor === Skip || lblock.id.clock + lblock.length <= nextExpectedClock)) {
lblock = leftRanges[++li]
}
// left: trim first op
if (lblock !== undefined && lblock.id.clock < nextExpectedClock && lblock.id.clock + lblock.length > nextExpectedClock) {
lblock = sliceStruct(lblock, lblock.id.clock + lblock.length - nextExpectedClock)
}
// left: add to result
while (lblock !== undefined && lblock.id.clock === nextExpectedClock && lblock.constructor !== Skip) {
addToResult(lblock)
lblock = leftRanges[++li]
}
}
const applyRight = () => {
// right: filter skips and known ops
while (rblock !== undefined && (rblock.constructor === Skip || rblock.id.clock + rblock.length <= nextExpectedClock)) {
rblock = rightRanges[++ri]
}
// right: trim first op
if (rblock !== undefined && rblock.id.clock < nextExpectedClock && rblock.id.clock + rblock.length > nextExpectedClock) {
rblock = sliceStruct(rblock, rblock.id.clock + rblock.length - nextExpectedClock)
}
// right: add to result
while (rblock !== undefined && rblock.id.clock === nextExpectedClock && rblock.constructor !== Skip) {
addToResult(rblock)
rblock = rightRanges[++ri]
}
}
for (; li < leftRanges.length && ri < rightRanges.length;) {
applyLeft()
applyRight()
// add skip if necessary
const minNextClock = math.min(lblock?.id.clock || 0, rblock?.id.clock || 0)
const gapSize = minNextClock - nextExpectedClock
if (gapSize > 0) {
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
}
}
while (li < leftRanges.length) {
applyLeft()
if (lblock !== undefined) {
const gapSize = lblock.id.clock - nextExpectedClock
if (gapSize > 0) {
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
}
}
}
while (ri < rightRanges.length) {
applyRight()
if (rblock !== undefined) {
const gapSize = rblock.id.clock - nextExpectedClock
if (gapSize > 0) {
addToResult(new Skip(new ID(clientid, nextExpectedClock), gapSize))
}
}
}
ranges.refs = result
}
}
})
inserts.clients.clear()
}
}

255
src/utils/Doc.js Normal file
View File

@@ -0,0 +1,255 @@
/**
* @module Y
*/
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'
import { StructStore } from './StructStore.js'
import { transact, generateNewClientId } from './Transaction.js'
import { YNode } from '../ynode.js'
import { $doc } from './schemas.js'
/**
* @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()
* @property {boolean} [DocOpts.isSuggestionDoc] Set to true if this document merely suggests
* changes. If this flag is not set in a suggestion document, automatic formatting changes will be
* displayed as suggestions, which might not be intended.
*/
/**
* @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<ArrayBuffer>, any, Doc, Transaction):void} DocEvents.update
* @property {function(Uint8Array<ArrayBuffer>, 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, isSuggestionDoc = false } = {}) {
super()
this.gc = gc
this.gcFilter = gcFilter
this.clientID = generateNewClientId()
this.guid = guid
this.collectionid = collectionid
this.isSuggestionDoc = isSuggestionDoc
this.cleanupFormatting = !isSuggestionDoc
/**
* @type {Map<string, YNode>}
*/
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
* @param {boolean} [local]
* @return T
*/
transact (f, origin = null, local = true) {
return transact(this, f, origin, local)
}
/**
* Define a shared data type.
*
* Multiple calls of `ydoc.get(name)` yield the same result
* and do not overwrite each other. I.e.
* `ydoc.get(name) === ydoc.get(name)`
*
* After this method is called, the type is also available on `ydoc.share.get(name)`.
*
* @param {string} key
* @param {string?} name Type-name
*
* @return {YNode}
*/
get (key = '', name = null) {
return map.setIfUndefined(this.share, key, () => {
const t = new YNode(name)
t._integrate(this, null)
return t
})
}
/**
* 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
// Tear down the top-level shared types as RDTs (emits their `'destroy'` event so any bindings
// unsubscribe). Only top-level types are destroyed here; nested child types are not recursed.
this.share.forEach(type => type.destroy())
array.from(this.subdocs).forEach(subdoc => subdoc.destroy())
const item = this._item
if (item !== null) {
this._item = null
const content = /** @type {ContentDoc} */ (item.content)
/**
* new content doc which replaces the new one
*/
const contentDoc = new Doc({ guid: this.guid, ...content.opts, shouldLoad: false })
content.doc = contentDoc
contentDoc._item = item
transact(/** @type {any} */ (item).parent.doc, transaction => {
if (!item.deleted) {
transaction.subdocsAdded.add(contentDoc)
}
transaction.subdocsRemoved.add(this)
}, null, true)
}
this.emit('destroy', [this])
super.destroy()
}
}
Doc.prototype.$type = $doc

87
src/utils/EventHandler.js Normal file
View File

@@ -0,0 +1,87 @@
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])

87
src/utils/ID.js Normal file
View File

@@ -0,0 +1,87 @@
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 {YNode<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

@@ -0,0 +1,344 @@
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as error from 'lib0/error'
import { Item, followRedone, ContentType } from '../structs/Item.js'
import { writeID, readID, compareIDs, findRootTypeKey, createID } from './ID.js'
import { rendererContentLength } from './renderer-helpers.js'
/**
* 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 {YNode<any>} type
* @param {number} index
* @param {number} [assoc]
*/
constructor (type, index, assoc = 0) {
/**
* @type {YNode<any>}
*/
this.type = type
/**
* @type {number}
*/
this.index = index
this.assoc = assoc
}
}
/**
* @param {YNode<any>} type
* @param {number} index
* @param {number} [assoc]
*
* @function
*/
export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc)
/**
* @param {YNode<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 {YNode} type The base type (e.g. YText or YArray).
* @param {number} index The absolute position.
* @param {number} [assoc]
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
* @return {RelativePosition}
*
* @function
*/
export const createRelativePositionFromTypeIndex = (type, index, assoc = 0, renderer = null) => {
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) {
const len = rendererContentLength(renderer, t)
if (len > index) {
// case 1: found position somewhere in the linked list
return createRelativePosition(type, createID(t.id.client, t.id.clock + index), assoc)
}
index -= len
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 = store.getItem(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
* @param {import('../utils/Renderer.js').AbstractRenderer?} renderer
* @return {AbsolutePosition|null}
*
* @function
*/
export const createAbsolutePositionFromRelativePosition = (rpos, doc, followUndoneDeletions = true, renderer = null) => {
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 (store.getClock(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 {YNode<any>} */ (right.parent)
// an index into a deleted type is meaningless - unless a renderer still renders the type
// (e.g. a deleted-but-rendered suggestion-mode subtree)
if (type._item === null || !type._item.deleted || rendererContentLength(renderer, type._item) > 0) {
index = rendererContentLength(renderer, right) === 0 ? 0 : (res.diff + (assoc >= 0 ? 0 : 1)) // adjust position based on left association if necessary
let n = right.left
while (n !== null) {
index += rendererContentLength(renderer, n)
n = n.left
}
}
} else {
if (tname !== null) {
type = doc.get(tname)
} else if (typeID !== null) {
if (store.getClock(typeID.client) <= typeID.clock) {
// type does not exist yet
return null
}
const { item } = followUndoneDeletions ? followRedone(store, typeID) : { item: store.getItem(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
)

702
src/utils/Renderer.js Normal file
View File

@@ -0,0 +1,702 @@
import { ObservableV2 } from 'lib0/observable'
import * as encoding from 'lib0/encoding'
import { getItemCleanStart } from './transaction-helpers.js'
import { diffIdSet, createInsertSetFromStructStore, createDeleteSetFromStructStore, insertIntoIdSet, mergeIdSets, intersectSets, createIdSet, createIdSetFromIdMap, writeIdSet, createIdMapFromIdSet, insertIntoIdMap, diffIdMap, createIdMap, mergeIdMaps, intersectMaps, createMaybeAttrRange, createContentAttribute } from './ids.js'
import { ContentDeleted, ContentFormat, ContentType } from '../structs/Item.js'
import { createID } from './ID.js'
import { writeStructsFromIdSet } from './encoding-helpers.js'
import { applyUpdate, encodeStateAsUpdate } from './encoding.js'
import { UpdateEncoderV1 } from './UpdateEncoder.js'
import { transact } from './Transaction.js'
import { UndoManager, StackItem } from './UndoManager.js'
import { $renderer, AttributedContent } from './renderer-helpers.js'
export { AbstractRenderer, rendererContentLength, $renderer } from './renderer-helpers.js'
/**
* Emit a single content piece into `contents`, applying the rendering rules of
* {@link AttributionsRenderer}. `inRC` is whether the piece is in `renderedContent` (renders normally,
* even when the item is deleted — a "restore"); `attrs` is its attribution (or `null`).
*
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {AbstractContent} c
* @param {number} clock
* @param {boolean} deleted - the item's doc deleted flag
* @param {boolean} inRC - whether this piece is in `renderedContent`
* @param {Array<ContentAttribute<any>>|null} attrs
* @param {0|1|2|3} shouldRender - see {@link AbstractRenderer#readContent}
*/
const pushAttributedPiece = (contents, c, clock, deleted, inRC, attrs, shouldRender) => {
if (inRC || attrs != null) {
// Visible: rendered normally (in `renderedContent`) and/or attributed. A restored deletion
// (`inRC`) renders as alive, so its effective `deleted` flag is cleared.
contents.push(new AttributedContent(c, clock, deleted && !inRC, attrs, shouldRender))
} else if (deleted && shouldRender !== 0 && shouldRender !== 3) {
// Genuinely deleted, unattributed, not restored: preserve the legacy retain/delete-op
// behavior (mode 3 renders unattributed deleted content as nothing — see DiffRenderer).
contents.push(new AttributedContent(c, clock, true, null, shouldRender))
}
// else: hidden alive content (or invisible deleted content under mode 0/3) — emit nothing.
}
/**
* Renders content with attributions, given a single `attributions` {@link ContentMap} of how to
* attribute content and an optional `renderedContent` {@link IdSet} of what renders.
*
* - `attributions` (inserts ∪ deletes) is merged into a single `renderAs` map; content it covers
* always renders, carrying its attribution.
* - `renderedContent` defines the content that renders *normally* — even if the item is marked
* deleted in the doc (a "restore"). It defaults to the doc's alive content (`inserts − deletes`),
* applied implicitly (a piece is in the default set ⟺ its item is not `deleted`), so the common
* "pure attribution overlay" case does no extra work. When a custom set is supplied it is
* authoritative: alive content omitted from it (and unattributed) is hidden.
*
* Restoring deleted content requires `gc: false` on the doc (the renderer does not rehydrate
* garbage-collected content, unlike {@link DiffRenderer}).
*
* @implements AbstractRenderer
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class AttributionsRenderer extends ObservableV2 {
/**
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
* @param {Object} [options]
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the
* doc's alive content (`inserts − deletes`), applied implicitly.
*/
constructor (attributions, { renderedContent = null } = {}) {
super()
/**
* The two attribution maps merged into one — `readContent` consults this for how to attribute
* a piece.
* @type {IdMap<any>}
*/
this.renderAs = mergeIdMaps([attributions.inserts, attributions.deletes])
/**
* Coverage of `renderAs` (ids that carry attributions). The small set `hasItem` checks. May
* over-approximate what actually renders — `readContent` remains authoritative. See
* {@link AbstractRenderer#attributed}.
* @type {IdSet}
*/
this.attributed = createIdSetFromIdMap(this.renderAs)
/**
* Custom "content that renders normally" set, or `null` to use the implicit default
* (alive ⟺ `!item.deleted`).
* @type {IdSet?}
*/
this.renderedContent = renderedContent
/**
* Union of `renderedContent` and `attributed` — the single cheap "does this produce visible
* output?" set. Only materialized when a custom `renderedContent` is supplied.
* @type {IdSet?}
*/
this.rendered = renderedContent === null ? null : mergeIdSets([renderedContent, this.attributed])
}
get $type () { return $renderer }
/**
* @param {Item} item
* @return {boolean}
*/
hasItem (item) {
const { client, clock } = item.id
if (this.attributed.intersects(client, clock, item.length)) return true
if (this.renderedContent === null) return false
// Custom renderedContent differs from the generic fast path for: deleted content that must be
// restored, and alive content that must be hidden (not fully covered).
return item.deleted
? this.renderedContent.intersects(client, clock, item.length)
: !this.renderedContent.covers(client, clock, item.length)
}
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} deleted
* @param {AbstractContent} content
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
*/
readContent (contents, client, clock, deleted, content, shouldRender) {
const total = content.getLength()
/**
* Segments of the item partitioned by `renderedContent`, or `null` when `inRC` is uniform over
* the whole item (the common case: default set, or a custom set that fully contains/excludes
* the item). `null` lets us slice `renderAs` once.
* @type {Array<import('./ids.js').MaybeIdRange>?}
*/
let outer = null
let inRC = !deleted // implicit default: alive ⟺ rendered normally
if (this.renderedContent !== null) {
outer = this.renderedContent.slice(client, clock, total)
if (outer.length === 1) {
inRC = outer[0].exists
outer = null
}
}
if (outer === null) {
// Uniform `inRC` — a single `renderAs` slice fixes the attribution boundaries.
const slice = this.renderAs.slice(client, clock, total)
let rest = slice.length === 1 ? content : content.copy()
for (let i = 0; i < slice.length; i++) {
const s = slice[i]
const c = rest
if (i < slice.length - 1) rest = c.splice(s.len)
pushAttributedPiece(contents, c, s.clock, deleted, inRC, s.attrs, shouldRender)
}
return
}
// General case: partition by `renderedContent` (outer, fixes `inRC`/effectiveDeleted), then by
// `renderAs` (inner, fixes attrs). Multiple outer segments ⇒ multi-id (string-like) content, so
// copying to avoid mutating the caller's content is safe.
let rest = content.copy()
for (let oi = 0; oi < outer.length; oi++) {
const rcSeg = outer[oi]
const slice = this.renderAs.slice(client, rcSeg.clock, rcSeg.len)
for (let ii = 0; ii < slice.length; ii++) {
const s = slice[ii]
const last = oi === outer.length - 1 && ii === slice.length - 1
const c = rest
if (!last) rest = c.splice(s.len)
pushAttributedPiece(contents, c, s.clock, deleted, rcSeg.exists, s.attrs, shouldRender)
}
}
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
if (!item.content.isCountable()) {
return 0
}
const { client, clock } = item.id
if (this.renderedContent === null) {
// Default: alive content renders in full; deleted content only where attributed.
return item.deleted ? this.renderAs.coveredLength(client, clock, item.length) : item.length
}
// Custom: rendered length is |item ∩ rendered| (renderedContent ∪ attributed).
return /** @type {IdSet} */ (this.rendered).coveredLength(client, clock, item.length)
}
}
/**
* @param {ContentMap} attributions - how to attribute content (`{ inserts, deletes }` IdMaps)
* @param {Object} [options]
* @param {IdSet?} [options.renderedContent] - content that renders normally; defaults to the doc's
* alive content (`inserts − deletes`).
*/
export const createAttributionsRenderer = (attributions, options) => new AttributionsRenderer(attributions, options)
/**
* @param {StructStore} store
* @param {number} client
* @param {number} clock
* @param {number} len
*/
const getItemContent = (store, client, clock, len) => {
// Retrieved item is never more fragmented than the newer item.
const prevItem = store.getItem(createID(client, clock))
const diffStart = clock - prevItem.id.clock
let content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
// trim itemContent to the correct size.
if (diffStart > 0) {
content = content.splice(diffStart)
}
if (len < content.getLength()) {
content.splice(len)
}
return content
}
/**
* Collect the whole subtree of an accepted pending node insert. A nested item can only exist in
* the suggestion doc if its parent does, so every attribute entry and every child of an accepted
* node belongs to the same suggestion - the attributes in particular: shipped without them, the
* node that reaches the base doc is not the node that was suggested (an image without its `src`,
* a paragraph without its text). Deleted nested items are transient (inserted and deleted within
* the suggestion) and are collected like the top-level `collectAll` case; the receiving document
* filters what it already holds.
*
* @param {import('../ynode.js').YNode} type
* @param {IdSet} inserts
* @param {IdSet} deletes
*/
const collectSubtree = (type, inserts, deletes) => {
/**
* @type {Array<Item|null>}
*/
const chains = [type._start]
type._map.forEach(item => { chains.push(item) })
for (const chain of chains) {
// list items chain to the right, map entries to the left (their overwritten values)
for (let item = chain; item != null; item = item.parentSub != null ? item.left : item.right) {
inserts.add(item.id.client, item.id.clock, item.length)
if (item.deleted) {
deletes.add(item.id.client, item.id.clock, item.length)
} else if (item.content instanceof ContentType) {
collectSubtree(item.content.type, inserts, deletes)
}
}
}
}
/**
* @param {Transaction?} tr - only specify this if you want to fill the content of deleted content
* @param {DiffRenderer} renderer
* @param {ID} start
* @param {ID} end
* @param {boolean} collectAll - collect as many items as possible. Accept adding redundant changes.
* An accepted node insert then also collects its subtree (see {@link collectSubtree}).
*/
const collectSuggestedChanges = (tr, renderer, start, end, collectAll) => {
const inserts = createIdSet()
const deletes = createIdSet()
const store = renderer._nextDoc.store
/**
* make sure to collect suggestions until all formats are closed
* @type {Set<string>}
*/
const openedCollectedFormats = new Set()
/**
* @type {Item?}
*/
let item = store.getItem(start)
const endItem = start === end ? item : (end == null ? null : store.getItem(end))
// walk to the left and find first un-attributed change that is rendered
while (item.left != null) {
item = item.left
if (item.content instanceof ContentFormat && item.content.value == null) {
item = item.right
break
}
if (!item.deleted) {
const slice = renderer.inserts.slice(item.id.client, item.id.clock, item.length)
if (slice.some(s => s.attrs === null)) {
for (let i = slice.length - 1; i >= 0; i--) {
const s = slice[i]
if (s.attrs == null) break
inserts.add(item.id.client, s.clock, s.len)
if (collectAll && item.content instanceof ContentType) {
collectSubtree(item.content.type, inserts, deletes)
}
}
item = item.right
break
}
}
}
let foundEndItem = false
// eslint-disable-next-line
itemLoop: while (item != null) {
const itemClient = item.id.client
const slice = (item.deleted ? renderer.deletes : renderer.inserts).slice(itemClient, item.id.clock, item.length)
foundEndItem ||= item === endItem
if (item.deleted) {
// item probably gc'd content. Need to split item and fill with content again
for (let i = slice.length - 1; i >= 0; i--) {
const s = slice[i]
if (s.attrs != null || collectAll) {
deletes.add(itemClient, s.clock, s.len)
if (collectAll) {
// in case item has been added and deleted this might be necessary. the forked document
// will automatically filter this if it doesn't have it already.
inserts.add(itemClient, s.clock, s.len)
}
}
if (tr != null) {
const splicedItem = getItemCleanStart(tr, createID(itemClient, s.clock))
if (s.attrs != null) {
splicedItem.content = getItemContent(renderer._prevDocStore, itemClient, s.clock, s.len)
}
}
}
} else {
if (item.content instanceof ContentFormat) {
const { key, value } = item.content
if (value == null) {
openedCollectedFormats.delete(key)
} else {
openedCollectedFormats.add(key)
}
}
for (let i = 0; i < slice.length; i++) {
const s = slice[i]
if (s.attrs != null) {
inserts.add(itemClient, s.clock, s.len)
if (collectAll && item.content instanceof ContentType) {
collectSubtree(item.content.type, inserts, deletes)
}
} else if (foundEndItem && openedCollectedFormats.size === 0) {
// eslint-disable-next-line
break itemLoop
}
}
}
item = item.right
}
return { inserts, deletes }
}
/**
* @param {IdMap<any>|undefined} attrs
* @param {IdSet} slice
*
*/
const extractAttributions = (attrs, slice) => attrs == null ? createIdMapFromIdSet(slice, []) : mergeIdMaps([intersectMaps(attrs, slice), createIdMapFromIdSet(slice, [])])
/**
* @implements AbstractRenderer
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class DiffRenderer extends ObservableV2 {
/**
* @param {Doc} prevDoc
* @param {Doc} nextDoc
* @param {Object} [options] - options for the renderer
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
*/
constructor (prevDoc, nextDoc, { attributions = null } = {}) {
super()
const _nextDocInserts = createInsertSetFromStructStore(nextDoc.store, false) // unmaintained
const _prevDocInserts = createInsertSetFromStructStore(prevDoc.store, false) // unmaintained
const nextDocDeletes = createDeleteSetFromStructStore(nextDoc.store) // maintained
const prevDocDeletes = createDeleteSetFromStructStore(prevDoc.store) // maintained
const insertDiff = diffIdSet(_nextDocInserts, _prevDocInserts)
const deleteDiff = diffIdSet(nextDocDeletes, prevDocDeletes)
this.inserts = extractAttributions(attributions?.inserts, insertDiff)
this.deletes = extractAttributions(attributions?.deletes, deleteDiff)
/**
* Raw coverage of `inserts` ∪ `deletes`, maintained alongside them. Over-approximates the
* actually-rendered set (e.g. it keeps suggested-inserts that were deleted later) —
* `readContent` remains authoritative. See {@link AbstractRenderer#attributed}.
* @type {IdSet}
*/
this.attributed = mergeIdSets([insertDiff, deleteDiff])
this._prevDoc = prevDoc
this._prevDocStore = prevDoc.store
this._nextDoc = nextDoc
// update before observer calls fired
this._nextBOH = nextDoc.on('beforeObserverCalls', tr => {
// update inserts
const diffInserts = diffIdSet(tr.insertSet, _prevDocInserts)
insertIntoIdMap(this.inserts, extractAttributions(attributions?.inserts, diffInserts))
// update deletes
const diffDeletes = diffIdSet(diffIdSet(tr.deleteSet, prevDocDeletes), this.inserts)
insertIntoIdMap(this.deletes, extractAttributions(attributions?.deletes, diffDeletes))
insertIntoIdSet(this.attributed, diffInserts)
insertIntoIdSet(this.attributed, diffDeletes)
// @todo fire update ranges on `diffInserts` and `diffDeletes`
})
this._prevBOH = prevDoc.on('beforeObserverCalls', tr => {
insertIntoIdSet(_prevDocInserts, tr.insertSet)
insertIntoIdSet(prevDocDeletes, tr.deleteSet)
if (tr.insertSet.clients.size < 2) {
tr.insertSet.forEach((attrRange, client) => {
this.inserts.delete(client, attrRange.clock, attrRange.len)
})
} else {
this.inserts = diffIdMap(this.inserts, tr.insertSet)
}
// insertIntoIdMap(this.deletes, createIdMapFromIdSet(intersectSets(tr.deleteSet, this.deletes), [createAttributionItem('acceptDelete', 'unknown')]))
if (tr.deleteSet.clients.size < 2) {
tr.deleteSet.forEach((attrRange, client) => {
this.deletes.delete(client, attrRange.clock, attrRange.len)
})
} else {
this.deletes = diffIdMap(this.deletes, tr.deleteSet)
}
// evict the accepted/rejected ranges from the coverage set, then re-add whatever the maps
// still claim: `tr.insertSet` only evicts insert-attributions and `tr.deleteSet` only evicts
// delete-attributions, but a single range can be covered by both maps (e.g. an accepted
// insert with a still-pending delete suggestion on the same ids must stay attributed).
const evicted = mergeIdSets([tr.insertSet, tr.deleteSet])
this.attributed = diffIdSet(this.attributed, evicted)
insertIntoIdSet(this.attributed, intersectSets(evicted, this.inserts))
insertIntoIdSet(this.attributed, intersectSets(evicted, this.deletes))
// fire event of "changed" attributions. exclude items that were added & deleted in the same
// transaction
this.emit('change', [diffIdSet(mergeIdSets([tr.insertSet, tr.deleteSet]), intersectSets(tr.insertSet, tr.deleteSet)), tr.origin, tr.local])
})
// changes from prevDoc should always flow into suggestionDoc
// changes from suggestionDoc only flow into ydoc if suggestion-mode is disabled
this._prevUpdateListener = prevDoc.on('update', (update, origin) => {
origin !== this && applyUpdate(nextDoc, update)
})
this._ndUpdateListener = nextDoc.on('update', (update, origin, _doc, tr) => {
// only if event is local and suggestion mode is enabled
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === origin))) {
applyUpdate(prevDoc, update, this)
}
})
this._afterTrListener = nextDoc.on('afterTransaction', (tr) => {
// apply deletes on attributed deletes (content that is already deleted, but is rendered by
// the renderer)
if (!this.suggestionMode && tr.local && (this.suggestionOrigins == null || this.suggestionOrigins.some(o => o === tr.origin))) {
const attributedDeletes = tr.meta.get('attributedDeletes')
if (attributedDeletes != null) {
transact(prevDoc, () => {
// apply attributed deletes if there are any
const ds = new UpdateEncoderV1()
encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs
writeIdSet(ds, attributedDeletes)
applyUpdate(prevDoc, ds.toUint8Array())
}, this)
}
}
})
this.suggestionMode = true
/**
* Optionally limit origins that may sync changes to the main doc if suggestion-mode is
* disabled.
*
* @type {Array<any>?}
*/
this.suggestionOrigins = null
this._destroyHandler = nextDoc.on('destroy', this.destroy.bind(this))
prevDoc.on('destroy', this._destroyHandler)
}
get $type () { return $renderer }
/**
* @param {Item} item
* @return {boolean}
*/
hasItem (item) {
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
}
destroy () {
super.destroy()
this._nextDoc.off('destroy', this._destroyHandler)
this._prevDoc.off('destroy', this._destroyHandler)
this._nextDoc.off('beforeObserverCalls', this._nextBOH)
this._prevDoc.off('beforeObserverCalls', this._prevBOH)
this._prevDoc.off('update', this._prevUpdateListener)
this._nextDoc.off('update', this._ndUpdateListener)
this._nextDoc.off('afterTransaction', this._afterTrListener)
}
acceptAllChanges () {
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
}
rejectAllChanges () {
this._prevDoc.transact(tr => {
applyUpdate(this._prevDoc, encodeStateAsUpdate(this._nextDoc))
const um = new UndoManager(this._prevDoc)
um.undoStack.push(new StackItem(tr.insertSet, tr.deleteSet))
um.undo()
um.destroy()
})
}
/**
* @param {ID} start
* @param {ID} end
*/
acceptChanges (start, end = start) {
const { inserts, deletes } = collectSuggestedChanges(null, this, start, end, true)
const encoder = new UpdateEncoderV1()
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
writeIdSet(encoder, deletes)
applyUpdate(this._prevDoc, encoder.toUint8Array())
}
/**
* @param {ID} start
* @param {ID} end
*/
rejectChanges (start, end = start) {
this._nextDoc.transact(tr => {
const { inserts, deletes } = collectSuggestedChanges(tr, this, start, end, false)
const encoder = new UpdateEncoderV1()
writeStructsFromIdSet(encoder, this._nextDoc.store, inserts)
writeIdSet(encoder, deletes)
const um = new UndoManager(this._nextDoc)
um.undoStack.push(new StackItem(inserts, deletes))
um.undo()
um.destroy()
})
this.acceptChanges(start, end)
}
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} deleted
* @param {AbstractContent} _content
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
*/
readContent (contents, client, clock, deleted, _content, shouldRender) {
const slice = (deleted ? this.deletes : this.inserts).slice(client, clock, _content.getLength())
/**
* @type {AbstractContent?}
*/
let content = slice.length === 1 ? _content : _content.copy()
for (let i = 0; i < slice.length; i++) {
const s = slice[i]
if (content == null || content instanceof ContentDeleted) {
if ((!shouldRender && s.attrs == null) || this.inserts.has(client, s.clock)) {
continue
}
// Retrieved item is never more fragmented than the newer item.
const prevItem = this._prevDocStore.getItem(createID(client, s.clock))
const diffStart = s.clock - prevItem.id.clock
content = prevItem.length > 1 ? prevItem.content.copy() : prevItem.content
// trim itemContent to the correct size.
if (diffStart > 0) {
content = content.splice(diffStart)
}
}
const c = /** @type {AbstractContent} */ (content)
const clen = c.getLength()
if (clen < s.len) {
slice.splice(i + 1, 0, createMaybeAttrRange(s.clock + clen, s.len - clen, s.attrs))
s.len = clen
}
content = s.len < clen ? c.splice(s.len) : null
// mode 3 (fresh content deleted in the same transaction) renders as an insert only where
// the renderer attributes it — unattributed deleted content is invisible and renders as
// *nothing* (there is nothing to insert, and a `delete` op would misapply: the consuming
// state has never seen this content)
if (!deleted || s.attrs != null || (shouldRender !== 0 && shouldRender !== 3)) {
contents.push(new AttributedContent(c, s.clock, deleted, s.attrs, shouldRender))
}
}
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
if (!item.deleted) {
return item.content.isCountable() ? item.length : 0
}
/**
* @type {Array<AttributedContent<any>>}
*/
const cs = []
this.readContent(cs, item.id.client, item.id.clock, true, item.content, 0)
return cs.reduce((cnt, c) => cnt + ((c.attrs != null && c.content.isCountable()) ? c.content.getLength() : 0), 0)
}
}
/**
* Attribute changes from ydoc1 to ydoc2.
*
* @param {Doc} prevDoc
* @param {Doc} nextDoc
* @param {Object} [options] - options for the renderer
* @param {ContentMap?} [options.attributions] - the attributions to apply to the diff
*/
export const createDiffRenderer = (prevDoc, nextDoc, options) => new DiffRenderer(prevDoc, nextDoc, options)
/**
* Intended for projects that used the v13 snapshot feature. With this renderer you can
* read content similar to the previous snapshot api. Requires that `ydoc.gc` is turned off.
*
* @implements AbstractRenderer
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class SnapshotRenderer extends ObservableV2 {
/**
* @param {Snapshot} prevSnapshot
* @param {Snapshot} nextSnapshot
* @param {Object} [options] - options for the renderer
* @param {Array<ContentAttribute>} [options.attrs] - the attributes to apply to the diff
*/
constructor (prevSnapshot, nextSnapshot) {
super()
this.prevSnapshot = prevSnapshot
this.nextSnapshot = nextSnapshot
const inserts = createIdMap()
const deletes = createIdMapFromIdSet(diffIdSet(nextSnapshot.ds, prevSnapshot.ds), [createContentAttribute('change', '')])
nextSnapshot.sv.forEach((clock, client) => {
const prevClock = prevSnapshot.sv.get(client) || 0
inserts.add(client, 0, prevClock, []) // content is included in prevSnapshot is rendered without attributes
inserts.add(client, prevClock, clock - prevClock, [createContentAttribute('change', '')]) // content is rendered as "inserted"
})
this.attrs = mergeIdMaps([diffIdMap(inserts, prevSnapshot.ds), deletes])
/**
* Coverage of `attrs` (everything up to `nextSnapshot`). Content *after* the snapshot must be
* hidden rather than rendered normally, which a finite set cannot express — `hasItem`
* additionally claims all future content. See {@link AbstractRenderer#attributed}.
* @type {IdSet}
*/
this.attributed = createIdSetFromIdMap(this.attrs)
}
get $type () { return $renderer }
/**
* @param {Item} item
* @return {boolean}
*/
hasItem (item) {
// claim future content (item ids at/after the snapshot's state vector, including clients the
// snapshot has never seen) — `readContent` hides it, the generic path would render it
return (this.nextSnapshot.sv.get(item.id.client) ?? 0) < item.id.clock + item.length ||
this.attributed.intersects(item.id.client, item.id.clock, item.length)
}
/**
* @param {Array<AttributedContent<any>>} contents - where to write the result
* @param {number} client
* @param {number} clock
* @param {boolean} _deleted
* @param {AbstractContent} content
* @param {0|1|2|3} shouldRender - whether this should render or just result in a `retain` operation (see AbstractRenderer#readContent)
*/
readContent (contents, client, clock, _deleted, content, shouldRender) {
if ((this.nextSnapshot.sv.get(client) ?? 0) <= clock) return // future item that should not be displayed
const slice = this.attrs.slice(client, clock, content.getLength())
content = slice.length === 1 ? content : content.copy()
slice.forEach(s => {
const deleted = this.nextSnapshot.ds.has(client, s.clock)
const nonExistend = (this.nextSnapshot.sv.get(client) ?? 0) <= s.clock
const c = content
if (s.len < c.getLength()) {
content = c.splice(s.len)
}
if (nonExistend) return
if (shouldRender || !deleted || (s.attrs != null && s.attrs.length > 0)) {
let attrsWithoutChange = s.attrs?.filter(attr => attr.name !== 'change') ?? null
if (s.attrs?.length === 0) {
attrsWithoutChange = null
}
contents.push(new AttributedContent(c, s.clock, deleted, attrsWithoutChange, shouldRender))
}
})
}
/**
* @param {Item} item
* @return {number}
*/
contentLength (item) {
return item.content.isCountable()
? (item.deleted
? this.attrs.sliceId(item.id, item.length).reduce((len, s) => s.attrs != null ? len + s.len : len, 0)
: item.length
)
: 0
}
}
/**
* @param {Snapshot} prevSnapshot
* @param {Snapshot} nextSnapshot
*/
export const createSnapshotRenderer = (prevSnapshot, nextSnapshot = prevSnapshot) => new SnapshotRenderer(prevSnapshot, nextSnapshot)

198
src/utils/Snapshot.js Normal file
View File

@@ -0,0 +1,198 @@
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import * as decoding from 'lib0/decoding'
import * as encoding from 'lib0/encoding'
import { createID } from './ID.js'
import { createDeleteSetFromStructStore, createIdSet, iterateStructsByIdSet, equalIdSets, mergeIdSets, writeIdSet, readIdSet } from './ids.js'
import { getStateVector } from './StructStore.js'
import { findIndexSS, getItemCleanStart } from './transaction-helpers.js'
import { writeStateVector, readStateVector, applyUpdateV2 } from './encoding.js'
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1, IdSetDecoderV2 } from './UpdateDecoder.js'
import { UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
import { LazyStructReader } from './updates.js'
import { Doc } from './Doc.js'
export class Snapshot {
/**
* @param {IdSet} ds
* @param {Map<number,number>} sv state map
*/
constructor (ds, sv) {
/**
* @type {IdSet}
*/
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 sv1 = snap1.sv
const sv2 = snap2.sv
if (sv1.size !== sv2.size) {
return false
}
for (const [key, value] of sv1.entries()) {
if (sv2.get(key) !== value) {
return false
}
}
return equalIdSets(snap1.ds, snap2.ds)
}
/**
* @param {Snapshot} snapshot
* @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
* @return {Uint8Array}
*/
export const encodeSnapshotV2 = (snapshot, encoder = new IdSetEncoderV2()) => {
writeIdSet(encoder, snapshot.ds)
writeStateVector(encoder, snapshot.sv)
return encoder.toUint8Array()
}
/**
* @param {Snapshot} snapshot
* @return {Uint8Array}
*/
export const encodeSnapshot = snapshot => encodeSnapshotV2(snapshot, new IdSetEncoderV1())
/**
* @param {Uint8Array} buf
* @param {IdSetDecoderV1 | IdSetDecoderV2} [decoder]
* @return {Snapshot}
*/
export const decodeSnapshotV2 = (buf, decoder = new IdSetDecoderV2(decoding.createDecoder(buf))) => {
return new Snapshot(readIdSet(decoder), readStateVector(decoder))
}
/**
* @param {Uint8Array} buf
* @return {Snapshot}
*/
export const decodeSnapshot = buf => decodeSnapshotV2(buf, new IdSetDecoderV1(decoding.createDecoder(buf)))
/**
* @param {IdSet} ds
* @param {Map<number,number>} sm
* @return {Snapshot}
*/
export const createSnapshot = (ds, sm) => new Snapshot(ds, sm)
export const emptySnapshot = createSnapshot(createIdSet(), new Map())
/**
* @param {Doc} doc
* @return {Snapshot}
*/
export const snapshot = doc => createSnapshot(createDeleteSetFromStructStore(doc.store), getStateVector(doc.store))
/**
* @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 < store.getClock(client)) {
getItemCleanStart(transaction, createID(client, clock))
}
})
iterateStructsByIdSet(transaction, snapshot.ds, _item => {})
meta.add(snapshot)
}
}
/**
* @example
* const ydoc = new Y.Doc({ gc: false })
* ydoc.get().insert(0, 'world!')
* const snapshot = Y.snapshot(ydoc)
* ydoc.get().insert(0, 'hello ')
* const restored = Y.createDocFromSnapshot(ydoc, snapshot)
* assert(restored.get().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 < originDoc.store.getClock(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, 0)
}
}
writeIdSet(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 = mergeIdSets([snapshot.ds, readIdSet(updateDecoder)])
return equalIdSets(snapshot.ds, mergedDS)
}
/**
* @param {Snapshot} snapshot
* @param {Uint8Array} update
*/
export const snapshotContainsUpdate = (snapshot, update) => snapshotContainsUpdateV2(snapshot, update, UpdateDecoderV1)

151
src/utils/StructStore.js Normal file
View File

@@ -0,0 +1,151 @@
import { Skip } from '../structs/Skip.js'
import { createID } from './ID.js'
import { createDeleteSetFromStructStore, createIdSet } from './ids.js'
import { findIndexSS } from './transaction-helpers.js'
export class StructStore {
constructor () {
/**
* @type {Map<number,Array<GC|Item|Skip>>}
*/
this.clients = new Map()
// this.ds = new IdSet()
/**
* @type {null | { missing: Map<number, number>, update: Uint8Array<ArrayBuffer> }}
*/
this.pendingStructs = null
/**
* @type {null | Uint8Array<ArrayBuffer>}
*/
this.pendingDs = null
this.skips = createIdSet()
}
get ds () {
return createDeleteSetFromStructStore(this)
}
/**
* @param {GC|Item|Skip} struct
* @function
*/
add (struct) {
let structs = this.clients.get(struct.id.client)
if (structs === undefined) {
structs = []
this.clients.set(struct.id.client, structs)
} else {
const lastStruct = structs[structs.length - 1]
if (lastStruct.id.clock + lastStruct.length !== struct.id.clock) {
// this replaces an integrated skip
let index = findIndexSS(structs, struct.id.clock)
const skip = structs[index]
const diffStart = struct.id.clock - skip.id.clock
const diffEnd = skip.id.clock + skip.length - struct.id.clock - struct.length
if (diffStart > 0) {
structs.splice(index++, 0, new Skip(createID(struct.id.client, skip.id.clock), diffStart))
}
if (diffEnd > 0) {
structs.splice(index + 1, 0, new Skip(createID(struct.id.client, struct.id.clock + struct.length), diffEnd))
}
structs[index] = struct
this.skips.delete(struct.id.client, struct.id.clock, struct.length)
return
}
}
structs.push(struct)
}
/**
* Expects that id is actually in store. This function throws or is an infinite loop otherwise.
*
* @param {ID} id
* @return {GC|Item}
*/
get (id) {
const structs = /** @type {Array<GC|Item>} */ (this.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.
*
* @param {ID} id
* @return {Item}
*/
getItem (id) {
const structs = /** @type {Array<GC|Item>} */ (this.clients.get(id.client))
return /** @type {Item} */ (structs[findIndexSS(structs, id.clock)])
}
/**
* Get the next expected clock for a specific client.
*
* @param {number} client
* @return {number}
*
* @public
* @function
*/
getClock (client) {
const structs = this.clients.get(client)
if (structs === undefined) {
return 0
}
const lastStruct = structs[structs.length - 1]
return lastStruct.id.clock + lastStruct.length
}
/**
* Perform a binary search on a sorted array
* @param {ID} id
* @return {{ structs: Array<GC|Item|Skip>, index: number }}
*
* @function
*/
getIndex (id) {
const structs = this.clients.get(id.client) || []
const index = findIndexSS(structs, id.clock)
return { structs, index }
}
}
/**
* 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)
})
store.skips.clients.forEach((range, client) => {
sm.set(client, range.getIds()[0].clock)
})
return sm
}
/**
* @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')
}
}
})
}

446
src/utils/Transaction.js Normal file
View File

@@ -0,0 +1,446 @@
import * as error from 'lib0/error'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as logging from 'lib0/logging'
import { callAll } from 'lib0/function'
import { ContentFormat } from '../structs/Item.js'
import { getStateVector } from './StructStore.js'
import { callEventHandlerListeners } from './EventHandler.js'
import { createIdSet, iterateStructsByIdSet } from './ids.js'
import { GC } from '../structs/GC.js'
import { YEvent } from './YEvent.js'
import { writeUpdateMessageFromTransaction } from './encoding-helpers.js'
import { UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
import { findIndexSS, updateCurrentFormats, cleanupFormattingGap, tryGcDeleteSet, tryMerge, tryToMergeWithLefts, cleanupContextlessFormattingGap } from './transaction-helpers.js'
import * as random from 'lib0/random'
export const generateNewClientId = random.uint53
/**
* 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.get('map')
* // Log content when change is triggered
* map.observe(() => {
* console.log('change triggered')
* })
* // Each change on the map type triggers a log message:
* map.setAttr('a', 0) // => "change triggered"
* map.setAttr('b', 0) // => "change triggered"
* // When put in a transaction, it will trigger the log after the transaction:
* ydoc.transact(() => {
* map.setAttr('a', 1)
* map.setAttr('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
*/
this.deleteSet = createIdSet()
/**
* Describes the set of items that are cleaned up / deleted by ids. It is a subset of
* this.deleteSet
*/
this.cleanUps = createIdSet()
/**
* Describes the set of inserted items by ids
*/
this.insertSet = createIdSet()
/**
* Holds the state before the transaction started.
* @type {Map<Number,Number>?}
*/
this._beforeState = null
/**
* Holds the state after the transaction.
* @type {Map<Number,Number>?}
*/
this._afterState = null
/**
* 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<YNode,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<YNode,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
this._done = false
}
/**
* Holds the state before the transaction started.
*
* @deprecated
* @type {Map<Number,Number>}
*/
get beforeState () {
if (this._beforeState == null) {
const sv = getStateVector(this.doc.store)
this.insertSet.clients.forEach((ranges, client) => {
sv.set(client, ranges.getIds()[0].clock)
})
this._beforeState = sv
}
return this._beforeState
}
/**
* Holds the state after the transaction.
*
* @deprecated
* @type {Map<Number,Number>}
*/
get afterState () {
if (!this._done) error.unexpectedCase()
if (this._afterState == null) {
const sv = getStateVector(this.doc.store)
this.insertSet.clients.forEach((_ranges, client) => {
const ranges = _ranges.getIds()
const d = ranges[ranges.length - 1]
sv.set(client, d.clock + d.len)
})
this._afterState = sv
}
return this._afterState
}
}
/**
* This function is experimental and subject to change / be removed.
*
* Ideally, we don't need this function at all. Formats should be cleaned up
* automatically after each change. This function iterates twice over the complete YText type
* and removes unnecessary formats. This is also helpful for testing.
*
* This function won't be exported anymore as soon as there is confidence that the YText type works as intended.
*
* @param {YNode} type
* @return {number} How many formats have been cleaned up.
*/
export const cleanupYTextFormatting = type => {
if (!type.doc?.cleanupFormatting) return 0
let res = 0
transact(/** @type {Doc} */ (type.doc), transaction => {
let start = /** @type {Item} */ (type._start)
let end = type._start
let startFormats = map.create()
const currentFormats = map.copy(startFormats)
while (end) {
if (end.deleted === false) {
switch (end.content.constructor) {
case ContentFormat:
updateCurrentFormats(currentFormats, /** @type {ContentFormat} */ (end.content))
break
default:
res += cleanupFormattingGap(transaction, start, end, startFormats, currentFormats)
startFormats = map.copy(currentFormats)
start = end
break
}
}
end = end.right
}
})
return res
}
/**
* @param {Array<Transaction>} transactionCleanups
* @param {number} i
*/
const cleanupTransactions = (transactionCleanups, i) => {
if (i < transactionCleanups.length) {
const transaction = transactionCleanups[i]
transaction._done = true
const doc = transaction.doc
const store = doc.store
const ds = transaction.deleteSet
const mergeStructs = transaction._mergeStructs
// insertIntoIdSet(store.ds, ds)
try {
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. Deleted types are included: `callTypeObservers` tracks
// the event in `changedParentTypes` unconditionally (so changes inside a deleted type — e.g.
// a suggestion-deleted tombstone that a custom renderer still renders — bubble to live
// ancestors) and decides itself whether the type's own observers fire.
transaction.changed.forEach((subs, itemtype) =>
fs.push(() => {
itemtype._callObserver(transaction, subs)
})
)
fs.push(() => {
// deep observe events + the RDT `'delta'` channel. `changedParentTypes` holds the changed
// type AND all of its ancestors, so both `observeDeep` and `'delta'` bubble identically.
transaction.changedParentTypes.forEach((events, type) => {
// We need to think about the possibility that the user transforms the
// Y.Doc in the event.
// Deleted types are tracked (so changes inside them bubble to live ancestors) but fire
// only while something still renders them — i.e. they have a renderer attached.
if (type._item !== null && type._item.deleted && type._renderer === null) return
const hasDeep = type._dEH.l.length > 0
const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0
const maintaining = type._delta !== null
if (!hasDeep && !hasDeltaListeners && !maintaining) return
/**
* @type {YEvent<any>}
*/
const deepEventHandler = events.find(event => event.target === type) || new YEvent(type, transaction, new Set(null))
if (hasDeep) {
callEventHandlerListeners(type._dEH, deepEventHandler, transaction)
}
if (hasDeltaListeners || maintaining) {
// the type-rooted deep delta of this transaction (a nested `modify` chain for ancestors)
const change = /** @type {any} */ (deepEventHandler.getDelta({ renderer: type._renderer, deep: true }).done())
// a base-renderer type doesn't render deleted content, so a change that happened
// entirely inside a deleted subtree renders to an empty delta — don't emit those no-ops
if (!change.isEmpty()) {
type._delta?.apply(change) // keep the cache current (incl. ancestors and diff-renderer attributions) — builder API only, see the YNode._delta fingerprint-memo invariant
if (hasDeltaListeners) type.emit('delta', [change, transaction.origin])
}
}
})
})
fs.push(() => doc.emit('afterTransaction', [transaction, doc]))
callAll(fs, [])
if (transaction._needFormattingCleanup && doc.cleanupFormatting) {
cleanupYTextAfterTransaction(transaction)
}
} finally {
// Replace deleted items with ItemDeleted / GC.
// This is where content is actually remove from the Yjs Doc.
if (doc.gc) {
tryGcDeleteSet(transaction, ds, doc.gcFilter)
}
tryMerge(ds, store)
// on all affected store.clients props, try to merge
transaction.insertSet.clients.forEach((ids, client) => {
const firstClock = ids.getIds()[0].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, firstClock), 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.insertSet.clients.has(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)
}
}
}
}
/**
* This will be called by the transaction once the event handlers are called to potentially cleanup
* formats.
*
* @param {Transaction} transaction
*/
export const cleanupYTextAfterTransaction = transaction => {
/**
* @type {Set<YNode>}
*/
const needFullCleanup = new Set()
// check if another formatting item was inserted
const doc = transaction.doc
iterateStructsByIdSet(transaction, transaction.insertSet, (item) => {
if (
!item.deleted && /** @type {Item} */ (item).content.constructor === ContentFormat && item.constructor !== GC
) {
needFullCleanup.add(/** @type {any} */ (item).parent)
}
})
// cleanup in a new transaction
transact(doc, (t) => {
iterateStructsByIdSet(transaction, transaction.deleteSet, item => {
if (item instanceof GC || !(/** @type {YNode} */ (item.parent)._hasFormatting) || needFullCleanup.has(/** @type {YNode} */ (item.parent))) {
return
}
const parent = /** @type {YNode} */ (item.parent)
if (item.content.constructor === ContentFormat) {
needFullCleanup.add(parent)
} else {
// If no format was inserted or deleted, we can make due with contextless
// formatting cleanups.
// Contextless: it is not necessary to compute currentFormats for the affected position.
cleanupContextlessFormattingGap(t, item)
}
})
// If a formatting item was inserted, we simply clean the whole type.
// We need to compute currentFormats for the current position anyway.
for (const yText of needFullCleanup) {
cleanupYTextFormatting(yText)
}
})
}
/**
* Implements the functionality of `y.transact(()=>{..})`
*
* @template T
* @param {Doc} doc
* @param {function(Transaction):T} f
* @param {any} [origin=true]
* @param {boolean} [local=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
}

545
src/utils/UndoManager.js Normal file
View File

@@ -0,0 +1,545 @@
import * as time from 'lib0/time'
import * as array from 'lib0/array'
import * as logging from 'lib0/logging'
import { ObservableV2 } from 'lib0/observable'
import { mergeIdSets, iterateStructsByIdSet, diffIdSet } from './ids.js'
import { Item, followRedone } from '../structs/Item.js'
import { transact } from './Transaction.js'
import { createID } from './ID.js'
import { isParentOf } from './isParentOf.js'
import { getItemCleanStart } from './transaction-helpers.js'
import { Doc } from './Doc.js'
import { YNode } from '../ynode.js'
export class StackItem {
/**
* @param {IdSet} insertions
* @param {IdSet} deletions
*/
constructor (insertions, deletions) {
this.inserts = insertions
this.deletes = 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) => {
iterateStructsByIdSet(tr, stackItem.deletes, item => {
if (item instanceof Item && um.scope.some(type => type === tr.doc || isParentOf(/** @type {YNode} */ (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
iterateStructsByIdSet(transaction, stackItem.inserts, 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 {YNode} */ (type), /** @type {Item} */ (struct)))) {
itemsToDelete.push(struct)
}
}
})
iterateStructsByIdSet(transaction, stackItem.deletes, struct => {
if (
struct instanceof Item &&
scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (type), struct)) &&
// Never redo structs in stackItem.insertions because they were created and deleted in the same capture interval.
!stackItem.inserts.hasId(struct.id)
) {
itemsToRedo.add(struct)
}
})
itemsToRedo.forEach(struct => {
performedChange = redoItem(transaction, struct, itemsToRedo, stackItem.inserts, undoManager.ignoreRemoteAttributeChanges, 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} [ignoreRemoteAttributeChanges] By default, the UndoManager will never overwrite remote changes. In some cases this might be the expected behavior. This property enables overwriting remote changes on attribute changes. (previously named `ignoreRemoteMapChanges`)
* @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<YNode,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|YNode|Array<YNode>} 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]),
ignoreRemoteAttributeChanges = false,
doc = /** @type {Doc} */ (array.isArray(typeScope) ? typeScope[0].doc : typeScope instanceof Doc ? typeScope : typeScope.doc)
} = {}) {
super()
/**
* @type {Array<YNode | 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.ignoreRemoteAttributeChanges = ignoreRemoteAttributeChanges
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 {YNode} */ (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 = transaction.insertSet
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.deletes = mergeIdSets([lastOp.deletes, transaction.deleteSet])
lastOp.inserts = mergeIdSets([lastOp.inserts, insertions])
} else {
// create a new stack op
stack.push(new StackItem(insertions, transaction.deleteSet))
didAdd = true
}
if (!undoing && !redoing) {
this.lastChange = now
}
// make sure that deleted structs are not gc'd
iterateStructsByIdSet(transaction, transaction.deleteSet, /** @param {Item|GC} item */ item => {
if (item instanceof Item && this.scope.some(type => type === transaction.doc || isParentOf(/** @type {YNode} */ (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.destroy = this.destroy.bind(this)
this.doc.on('afterTransaction', this.afterTransactionHandler)
this.doc.on('destroy', this.destroy)
}
/**
* Extend the scope.
*
* @param {Array<YNode | Doc> | YNode | Doc} ynodes
*/
addToScope (ynodes) {
const tmpSet = new Set(this.scope)
ynodes = array.isArray(ynodes) ? ynodes : [ynodes]
ynodes.forEach(ynode => {
if (!tmpSet.has(ynode)) {
tmpSet.add(ynode)
if (ynode instanceof YNode ? ynode.doc !== this.doc : ynode !== 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(ynode)
}
})
}
/**
* @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)
this.doc.off('destroy', this.destroy)
super.destroy()
}
}
/**
* @experimental
*
* This is not guaranteed to work on documents with gc enabled!
*
* @param {Doc} ydoc
* @param {ContentIds} contentIds
* @param {UndoManagerOptions} opts
*/
export const undoContentIds = (ydoc, contentIds, opts = {}) => {
const um = new UndoManager(ydoc, opts)
um.undoStack.push(new StackItem(diffIdSet(contentIds.inserts, contentIds.deletes), diffIdSet(contentIds.deletes, contentIds.inserts)))
um.undo()
}
/**
* @param {Array<StackItem>} stack
* @param {ID} id
*/
const isDeletedByUndoStack = (stack, id) => array.some(stack, /** @param {StackItem} s */ s => s.deletes.hasId(id))
/**
* Redoes the effect of this operation.
*
* @param {Transaction} transaction The Yjs instance.
* @param {Item} item
* @param {Set<Item>} redoitems
* @param {IdSet} itemsToDelete
* @param {boolean} ignoreRemoteAttributeChanges
* @param {import('../utils/UndoManager.js').UndoManager} um
*
* @return {Item|null}
*
* @private
*/
export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemoteAttributeChanges, 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 {YNode} */ (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, ignoreRemoteAttributeChanges, um) === null)) {
return null
}
while (parentItem.redone !== null) {
parentItem = getItemCleanStart(transaction, parentItem.redone)
}
}
/**
* @type {YNode}
*/
const parentType = /** @type {YNode} */ (parentItem === null ? 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 {YNode} */ (leftTrace.parent)._item !== parentItem) {
leftTrace = leftTrace.redone === null ? null : getItemCleanStart(transaction, leftTrace.redone)
}
if (leftTrace !== null && /** @type {YNode} */ (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 {YNode} */ (rightTrace.parent)._item !== parentItem) {
rightTrace = rightTrace.redone === null ? null : getItemCleanStart(transaction, rightTrace.redone)
}
if (rightTrace !== null && /** @type {YNode} */ (rightTrace.parent)._item === parentItem) {
right = rightTrace
break
}
right = right.right
}
} else {
right = null
if (item.right && !ignoreRemoteAttributeChanges) {
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 || itemsToDelete.hasId(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
}
if (left !== null && /** @type {YNode} */ (left.parent)._item !== parentItem) {
left = parentType._map.get(item.parentSub) || null
}
}
const nextClock = store.getClock(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
}
/**
* 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 {YNode} */ (item.parent)._item
}
}

284
src/utils/UpdateDecoder.js Normal file
View File

@@ -0,0 +1,284 @@
import * as buffer from 'lib0/buffer'
import * as decoding from 'lib0/decoding'
import { ID, createID } from './ID.js'
/**
* @typedef {IdSetDecoderV1 | IdSetDecoderV2} IdSetDecoder
*/
export class IdSetDecoderV1 {
/**
* @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 IdSetDecoderV1 {
/**
* @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 IdSetDecoderV2 {
/**
* @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 IdSetDecoderV2 {
/**
* @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
}
}
}

323
src/utils/UpdateEncoder.js Normal file
View File

@@ -0,0 +1,323 @@
import * as error from 'lib0/error'
import * as encoding from 'lib0/encoding'
export class IdSetEncoderV1 {
constructor () {
this.restEncoder = encoding.createEncoder()
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetIdSetCurVal () {
// nop
}
/**
* @param {number} clock
*/
writeIdSetClock (clock) {
encoding.writeVarUint(this.restEncoder, clock)
}
/**
* @param {number} len
*/
writeIdSetLen (len) {
encoding.writeVarUint(this.restEncoder, len)
}
}
export class UpdateEncoderV1 extends IdSetEncoderV1 {
/**
* @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 IdSetEncoderV2 {
constructor () {
this.restEncoder = encoding.createEncoder() // encodes all the rest / non-optimized
this.dsCurrVal = 0
}
toUint8Array () {
return encoding.toUint8Array(this.restEncoder)
}
resetIdSetCurVal () {
this.dsCurrVal = 0
}
/**
* @param {number} clock
*/
writeIdSetClock (clock) {
const diff = clock - this.dsCurrVal
this.dsCurrVal = clock
encoding.writeVarUint(this.restEncoder, diff)
}
/**
* @param {number} len
*/
writeIdSetLen (len) {
if (len === 0) {
error.unexpectedCase()
}
encoding.writeVarUint(this.restEncoder, len - 1)
this.dsCurrVal += len
}
}
export class UpdateEncoderV2 extends IdSetEncoderV2 {
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)
}
}
}
/**
* @typedef {IdSetEncoderV1 | IdSetEncoderV2} IdSetEncoder
*/
/**
* @typedef {UpdateEncoderV1 | UpdateEncoderV2} UpdateEncoder
*/

194
src/utils/YEvent.js Normal file
View File

@@ -0,0 +1,194 @@
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import { diffIdSet, intersectSets, mergeIdSets } from './ids.js'
import { createAbsolutePositionFromRelativePosition, createRelativePosition } from './RelativePosition.js'
/**
* @template {DeltaConf} DConf
* YEvent describes the changes on a YNode.
*/
export class YEvent {
/**
* @param {YNode<DConf>} target The changed type.
* @param {import('./Transaction.js').Transaction} transaction
* @param {Set<any>?} subs The keys that changed
*/
constructor (target, transaction, subs) {
/**
* The type on which this event was created on.
* @type {import('../ynode.js').YNode<DConf>}
*/
this.target = target
/**
* The current target on which the observe callback is called.
* @type {YNode<any>}
*/
this.currentTarget = target
/**
* The transaction that triggered this event.
* @type {import('./Transaction.js').Transaction}
*/
this.transaction = transaction
/**
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>|null}
*/
this._delta = null
/**
* @type {Delta<DConf>|null}
*/
this._deltaDeep = null
/**
* Whether the children changed.
* @type {Boolean}
* @private
*/
this.childListChanged = false
/**
* Set of all changed attributes.
* @type {Set<string>}
*/
this.keysChanged = new Set()
subs?.forEach((sub) => {
if (sub === null) {
this.childListChanged = true
} else {
this.keysChanged.add(sub)
}
})
}
/**
* 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 this.transaction.deleteSet.hasId(struct.id)
}
/**
* 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 this.transaction.insertSet.hasId(struct.id)
}
/**
* @template {boolean} [Deep=false]
* @param {object} [opts]
* @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to the target type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed
* @param {Deep} [opts.deep]
* @return {Deep extends true ? Delta<DConf> : Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
*
* @public
*/
getDelta ({ renderer = this.target._renderer, deep } = {}) {
const insertSet = this.transaction.insertSet
const deleteSet = this.transaction.deleteSet
// content that was both inserted AND deleted by this transaction is normally invisible — but a
// renderer may still render it (e.g. remote content integrated under a suggestion-deleted
// parent is auto-deleted yet rendered with a delete attribution). Render
// `(I ∪ D) − ((I ∩ D) − renderer.attributed)`: the symmetric difference plus the attributed
// part of the intersection.
const both = intersectSets(insertSet, deleteSet)
const itemsToRender = mergeIdSets(
(both.isEmpty() || renderer === null)
? [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet)]
: [diffIdSet(insertSet, deleteSet), diffIdSet(deleteSet, insertSet), intersectSets(both, renderer.attributed)]
)
/**
* @todo this should be done only one in the transaction step
*
* @type {Map<YNode,Set<string|null>>|null}
*/
let modified = this.transaction.changed
if (deep) {
// need to add deep changes to copy of modified
const dchanged = new Map()
modified.forEach((attrs, type) => {
dchanged.set(type, new Set(attrs))
})
for (let m of modified.keys()) {
while (m._item != null) {
const item = m._item
const ms = map.setIfUndefined(dchanged, item?.parent, set.create)
if (item && !ms.has(item.parentSub)) {
ms.add(item.parentSub)
m = /** @type {any} */ (item.parent)
} else {
break
}
}
}
modified = dchanged
}
return /** @type {any} */ (this.target.toDelta({ renderer, itemsToRender, retainDeletes: true, insertedItems: insertSet, deep: !!deep, modified }))
}
/**
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
*
* @type {Delta<import('../ynode.js').DeltaConfDeltaToYNode<DConf>>} The Delta representation of this type.
* @public
*/
get delta () {
return /** @type {any} */ (this._delta ?? (this._delta = this.getDelta().done()))
}
/**
* Compute the changes in the delta format.
* A {@link https://quilljs.com/docs/delta/|Quill Delta}) that represents the changes on the document.
*
* @type {Delta<DConf>} The Delta representation of this type.
* @public
*/
get deltaDeep () {
return /** @type {any} */ (this._deltaDeep ?? (this._deltaDeep = /** @type {any} */ (this.getDelta({ deep: true }))))
}
}
/**
* 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 {YNode} parent
* @param {YNode} child target
* @param {AbstractRenderer?} renderer
* @return {Array<string|number>} Path to the target
*
* @private
* @function
*/
export const getPathTo = (parent, child, renderer = null) => {
const path = []
const doc = /** @type {Doc} */ (parent.doc)
while (child._item !== null && child !== parent) {
if (child._item.parentSub !== null) {
// parent is map-ish
path.unshift(child._item.parentSub)
} else {
const parent = /** @type {import('../ynode.js').YNode} */ (child._item.parent)
// parent is array-ish
const apos = /** @type {import('../utils/RelativePosition.js').AbsolutePosition} */ (createAbsolutePositionFromRelativePosition(createRelativePosition(parent, child._item.id), doc, false, renderer))
path.unshift(apos.index)
}
child = /** @type {YNode} */ (child._item.parent)
}
return path
}

View File

View File

@@ -0,0 +1,32 @@
import * as delta from 'lib0/delta'
import { createInsertSetFromStructStore, createDeleteSetFromStructStore, diffIdSet, mergeIdSets } from './ids.js'
import { createDiffRenderer } from './Renderer.js'
import { computeModifiedFromItems } from '../ynode.js'
/**
* @param {Doc} v1
* @param {Doc} v2
* @return {delta.DeltaBuilderAny}
*/
export const diffDocsToDelta = (v1, v2, { renderer = createDiffRenderer(v1, v2) } = {}) => {
const insertDiff = diffIdSet(createInsertSetFromStructStore(v2.store, false), createInsertSetFromStructStore(v1.store, false))
const deleteDiff = diffIdSet(createDeleteSetFromStructStore(v2.store), createDeleteSetFromStructStore(v1.store))
// don't render items that have been inserted and then deleted
const insertsOnly = diffIdSet(insertDiff, deleteDiff)
const itemsToRender = mergeIdSets([insertsOnly, deleteDiff])
/**
* @type {Map<YNode, Set<string|null>>}
*/
const changedTypes = computeModifiedFromItems(v2.store, itemsToRender)
const d = delta.create()
v2.share.forEach((type, typename) => {
const typeConf = changedTypes.get(type)
if (typeConf) {
const shareDelta = type.toDelta({
renderer, itemsToRender, retainDeletes: true, modified: changedTypes, deep: true
})
d.modifyAttr(typename, shareDelta)
}
})
return d
}

View File

@@ -0,0 +1,110 @@
import * as encoding from 'lib0/encoding'
import * as math from 'lib0/math'
import * as array from 'lib0/array'
import { findIndexSS } from './transaction-helpers.js'
import { Skip } from '../structs/Skip.js'
import { createID } from './ID.js'
import { writeIdSet } from './ids.js'
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Array<GC|Item|Skip>} structs All structs by `client`
* @param {number} client
* @param {Array<IdRange>} idranges
*
* @function
*/
export const writeStructs = (encoder, structs, client, idranges) => {
let structsToWrite = 0 // this accounts for the skips
/**
* @type {Array<{ start: number, end: number, startClock: number, endClock: number }>}
*/
const indexRanges = []
const firstPossibleClock = structs[0].id.clock
const lastStruct = array.last(structs)
const lastPossibleClock = lastStruct.id.clock + lastStruct.length
idranges.forEach(idrange => {
const startClock = math.max(idrange.clock, firstPossibleClock)
const endClock = math.min(idrange.clock + idrange.len, lastPossibleClock)
if (startClock >= endClock) return // structs for this range do not exist
// inclusive start
const start = findIndexSS(structs, startClock)
// exclusive end
const end = findIndexSS(structs, endClock - 1) + 1
structsToWrite += end - start
indexRanges.push({
start,
end,
startClock,
endClock
})
})
structsToWrite += idranges.length - 1
// start writing with this clock. this is updated to the next clock that we expect to write
let clock = indexRanges[0].startClock
// write # encoded structs
encoding.writeVarUint(encoder.restEncoder, structsToWrite)
encoder.writeClient(client)
// write clock
encoding.writeVarUint(encoder.restEncoder, clock)
indexRanges.forEach(indexRange => {
const skipLen = indexRange.startClock - clock
if (skipLen > 0) {
new Skip(createID(client, clock), skipLen).write(encoder, 0)
clock += skipLen
}
for (let i = indexRange.start; i < indexRange.end; i++) {
const struct = structs[i]
const structEnd = struct.id.clock + struct.length
const offsetEnd = math.max(structEnd - indexRange.endClock, 0)
struct.write(encoder, clock - struct.id.clock, offsetEnd)
clock = structEnd - offsetEnd
}
})
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {StructStore} store
* @param {IdSet} idset
*
* @todo at the moment this writes the full deleteset range
*
* @private
* @function
*/
export const writeStructsFromIdSet = (encoder, store, idset) => {
// write # states that were updated
encoding.writeVarUint(encoder.restEncoder, idset.clients.size)
// Write items with higher client ids first
// This heavily improves the conflict algorithm.
array.from(idset.clients.entries()).sort((a, b) => b[0] - a[0]).forEach(([client, ids]) => {
const idRanges = ids.getIds()
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
writeStructs(encoder, structs, client, idRanges)
})
}
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
*
* @private
* @function
*/
export const writeStructsFromTransaction = (encoder, transaction) => writeStructsFromIdSet(encoder, transaction.doc.store, transaction.insertSet)
/**
* @param {UpdateEncoderV1 | UpdateEncoderV2} encoder
* @param {Transaction} transaction
* @return {boolean} Whether data was written.
*/
export const writeUpdateMessageFromTransaction = (encoder, transaction) => {
if (transaction.deleteSet.clients.size === 0 && transaction.insertSet.clients.size === 0) {
return false
}
writeStructsFromTransaction(encoder, transaction)
writeIdSet(encoder, transaction.deleteSet)
return true
}

697
src/utils/encoding.js Normal file
View File

@@ -0,0 +1,697 @@
/**
* @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 * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as map from 'lib0/map'
import * as math from 'lib0/math'
import * as array from 'lib0/array'
import { getStateVector, StructStore } from './StructStore.js'
import { getItemCleanStart, getItemCleanEnd } from './transaction-helpers.js'
import { createIdSet, IdRange, readAndApplyDeleteSet, readIdSet, mergeIdSets, writeIdSet } from './ids.js'
import { createID, ID } from './ID.js'
import { UpdateDecoderV1, UpdateDecoderV2, IdSetDecoderV1 } from './UpdateDecoder.js'
import { UpdateEncoderV1, UpdateEncoderV2, IdSetEncoderV1, IdSetEncoderV2 } from './UpdateEncoder.js'
import { convertUpdateFormatV2ToV1, LazyStructReader, LazyStructWriter, writeStructToLazyStructWriter, finishLazyStructWriting } from './updates.js'
import { readBlockSet, writeBlockSet } from './BlockSet.js'
import { Skip } from '../structs/Skip.js'
import { Item } from '../structs/Item.js'
import { GC } from '../structs/GC.js'
import { Doc } from './Doc.js'
import { writeStructs } from './encoding-helpers.js'
/**
* @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 (store.getClock(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]) => {
const structs = /** @type {Array<GC|Item>} */ (store.clients.get(client))
const lastStruct = structs[structs.length - 1]
writeStructs(encoder, structs, client, [new IdRange(clock, lastStruct.id.clock + lastStruct.length - clock)])
})
}
/**
* 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 {BlockSet} clientsStructRefs
* @return { null | { update: Uint8Array<ArrayBuffer>, missing: Map<number,number> } }
*
* @private
* @function
*/
const integrateStructs = (transaction, store, clientsStructRefs) => {
/**
* @type {Array<Item | GC | Skip>}
*/
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.clients.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.clients.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.clients.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|Skip}
*/
let stackHead = /** @type {any} */ (curStructsTarget).refs[/** @type {any} */ (curStructsTarget).i++]
// caching the state because it is used very often
const state = new Map()
// // caching the state because it is used very often
// const currentInsertSet = createIdSet()
// clientsStructRefsIds.forEach(clientId => {
// currentInsertSet.clients.set(clientid, new IdRanges(_createInsertSliceFromStructs(store.clients.get(clientId) ?? [], false)))
// })
const addStackToRestSS = () => {
for (const item of stack) {
const client = item.id.client
const inapplicableItems = clientsStructRefs.clients.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.clients.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, () => store.getClock(stackHead.id.client))
const offset = localClock - stackHead.id.clock
const missing = getMissing(/** @type {any} */ (stackHead), transaction, store)
if (missing !== null) {
stack.push(stackHead)
// get the struct reader that has the missing struct
/**
* @type {{ refs: Array<GC|Item|Skip>, i: number }}
*/
const structRefs = clientsStructRefs.clients.get(/** @type {number} */ (missing)) || { refs: [], i: 0 }
if (structRefs.refs.length === structRefs.i || missing === stackHead.id.client || stack.some(s => s.id.client === missing)) { // @todo this could be optimized!
// This update message causally depends on another update message that doesn't exist yet
updateMissingSv(/** @type {number} */ (missing), store.getClock(missing))
addStackToRestSS()
} else {
stackHead = structRefs.refs[structRefs.i++]
continue
}
} else {
// all fine, apply the stackhead
// but first add a skip to structs if necessary
if (offset < 0) {
const skip = new Skip(createID(stackHead.id.client, localClock), -offset)
skip.integrate(transaction, 0)
}
stackHead.integrate(transaction, 0)
state.set(stackHead.id.client, math.max(stackHead.id.clock + stackHead.length, localClock))
}
}
// 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
}
/**
* 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)) =>
ydoc.transact(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 = readBlockSet(structDecoder)
const knownState = createIdSet()
ss.clients.forEach((_, client) => {
const storeStructs = store.clients.get(client)
if (storeStructs) {
const last = storeStructs[storeStructs.length - 1]
knownState.add(client, 0, last.id.clock + last.length)
// remove known items from ss
store.skips.clients.get(client)?.getIds().forEach(idrange => {
knownState.delete(client, idrange.clock, idrange.len)
})
}
})
// remove known items from ss
ss.exclude(knownState)
// 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 (ss.clients.has(client) || clock < store.getClock(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)
writeIdSet(encoder, doc.store.ds)
}
/**
* 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<ArrayBuffer>}
*
* @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<ArrayBuffer>}
*
* @function
*/
export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1())
/**
* Read state vector from Decoder and return as Map
*
* @param {IdSetDecoderV1 | IdSetDecoderV2} 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
}
/**
*
* This function works similarly to `readUpdateV2`.
*
* @param {Array<Uint8Array<ArrayBuffer>>} updates
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
* @return {Uint8Array<ArrayBuffer>}
*/
export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
if (updates.length === 1) {
return updates[0]
} else if (updates.length === 0) {
return encodeStateAsUpdateV2(new Doc(), new Uint8Array([0]), new YEncoder())
}
const updateDecoders = updates.map(update => new YDecoder(decoding.createDecoder(update)))
const blocksets = updateDecoders.map(dec => readBlockSet(dec))
const mergedBlockset = blocksets[0]
for (let i = 1; i < blocksets.length; i++) {
mergedBlockset.insertInto(blocksets[i])
}
const updateEncoder = new YEncoder()
writeBlockSet(updateEncoder, mergedBlockset)
const dss = updateDecoders.map(decoder => readIdSet(decoder))
const ds = mergeIdSets(dss)
writeIdSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @param {Array<Uint8Array<ArrayBuffer>>} updates
* @return {Uint8Array<ArrayBuffer>}
*/
export const mergeUpdates = updates => mergeUpdatesV2(updates, UpdateDecoderV1, UpdateEncoderV1)
/**
* @deprecated
* @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), 0)
reader.next()
while (reader.curr && reader.curr.id.client === currClient) {
writeStructToLazyStructWriter(lazyStructWriter, reader.curr, 0, 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 = readIdSet(decoder)
writeIdSet(encoder, ds)
return encoder.toUint8Array()
}
/**
* @deprecated
* @todo remove this in favor of intersectupdate
*
* @param {Uint8Array<ArrayBuffer>} update
* @param {Uint8Array<ArrayBuffer>} sv
*/
export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1)
/**
* 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 IdSetDecoderV1(decoding.createDecoder(decodedState)))
/**
* @param {IdSetEncoderV1 | IdSetEncoderV2} 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 {IdSetEncoderV1 | IdSetEncoderV2} 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 {IdSetEncoderV1 | IdSetEncoderV2} [encoder]
* @return {Uint8Array<ArrayBuffer>}
*
* @function
*/
export const encodeStateVectorV2 = (doc, encoder = new IdSetEncoderV2()) => {
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<ArrayBuffer>}
*
* @function
*/
export const encodeStateVector = doc => encodeStateVectorV2(doc, new IdSetEncoderV1())
/**
* Return the creator clientID of the missing op or define missing items and return null.
*
* @param {Item} struct
* @param {Transaction} transaction
* @param {StructStore} store
* @return {null | number}
*/
const getMissing = (struct, transaction, store) => {
if (struct.constructor !== Item) return null
// we may not access these variables anymore after they have been written!
const origin = struct.origin
const rightOrigin = struct.rightOrigin
const parent = struct.parent
if (origin && (origin.clock >= store.getClock(origin.client) || store.skips.hasId(origin))) {
return origin.client
}
if (rightOrigin && (rightOrigin.clock >= store.getClock(rightOrigin.client) || store.skips.hasId(rightOrigin))) {
return rightOrigin.client
}
if (parent && parent.constructor === ID && (parent.clock >= store.getClock(parent.client) || store.skips.hasId(parent))) {
return parent.client
}
// We have all missing ids, now find the items
if (origin) {
struct.left = getItemCleanEnd(transaction, store, origin)
// copy left id to so that the original id can be gc'd
struct.origin = struct.left.lastId
}
if (rightOrigin) {
struct.right = getItemCleanStart(transaction, rightOrigin)
struct.rightOrigin = struct.right.id
}
if ((struct.left && struct.left.constructor === GC) || (struct.right && struct.right.constructor === GC)) {
struct.parent = null
} else if (parent == null) {
// only set parent if this shouldn't be garbage collected
if (struct.left && struct.left.constructor === Item) {
struct.parent = struct.left.parent
struct.parentSub = struct.left.parentSub
} else if (struct.right && struct.right.constructor === Item) {
struct.parent = struct.right.parent
struct.parentSub = struct.right.parentSub
}
} else if (parent.constructor === ID) {
const parentItem = store.getItem(parent)
if (parentItem.constructor === GC) {
struct.parent = null
} else {
struct.parent = /** @type {ContentType} */ (parentItem.content).type
}
} else if (typeof parent === 'string') {
struct.parent = transaction.doc.get(parent)
}
return null
}
/**
* @param {Uint8Array} update
* @param {import('./Doc.js').DocOpts} opts
*/
export const createDocFromUpdate = (update, opts = {}) => {
const ydoc = new Doc(opts)
applyUpdate(ydoc, update)
return ydoc
}
/**
* @param {Uint8Array} update
* @param {import('./Doc.js').DocOpts} opts
*/
export const createDocFromUpdateV2 = (update, opts = {}) => {
const ydoc = new Doc(opts)
applyUpdateV2(ydoc, update)
return ydoc
}
/**
* @param {Doc} ydoc
* @param {import('./Doc.js').DocOpts} [opts]
*/
export const cloneDoc = (ydoc, opts) => {
const clone = new Doc(opts)
applyUpdate(clone, encodeStateAsUpdate(ydoc))
return clone
}

1752
src/utils/ids.js Normal file

File diff suppressed because it is too large Load Diff

19
src/utils/isParentOf.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* Check if `parent` is a parent of `child`.
*
* @param {YNode} 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 {YNode} */ (child.parent)._item
}
return false
}

17
src/utils/logging.js Normal file
View File

@@ -0,0 +1,17 @@
/**
* Convenient helper to log type information.
*
* Do not use in productive systems as the output can be immense!
*
* @param {YNode<any>} type
*/
export const logNode = 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))
}

230
src/utils/meta.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* Meta API for describing Yjs documents
*/
import * as decoding from 'lib0/decoding'
import * as s from 'lib0/schema'
import {
$idSet,
$idMapAny,
createIdSetFromIdMap,
createDeleteSetFromStructStore,
createInsertSetFromStructStore,
diffIdMap,
mergeIdMaps,
createIdSet,
createIdMap,
mergeIdSets,
diffIdSet,
createIdMapFromIdSet,
writeIdSet,
readIdSet,
writeIdMap,
readIdMap,
intersectMaps,
intersectSets,
filterIdMap
} from './ids.js'
import { IdSetEncoderV2 } from './UpdateEncoder.js'
import { IdSetDecoderV2 } from './UpdateDecoder.js'
/**
* @param {IdSet} inserts
* @param {IdSet} deletes
* @return {ContentIds}
*/
export const createContentIds = (inserts = createIdSet(), deletes = createIdSet()) => ({ inserts, deletes })
/**
* @param {ContentMap} contentMap
*/
export const createContentIdsFromContentMap = contentMap => createContentIds(
createIdSetFromIdMap(contentMap.inserts),
createIdSetFromIdMap(contentMap.deletes)
)
/**
* @param {import('./Doc.js').Doc} ydoc
* @param {boolean} insertsContainDeletes - whether the returned `inserts` set includes content that
* the doc has already deleted. `true` yields a full structural snapshot (every inserted id);
* `false` yields only the doc's currently-visible content (`inserts − deletes`), which is what you
* want when the snapshot defines what should render.
*/
export const createContentIdsFromDoc = (ydoc, insertsContainDeletes) => createContentIds(
createInsertSetFromStructStore(ydoc.store, !insertsContainDeletes),
createDeleteSetFromStructStore(ydoc.store)
)
/**
* @param {import('./Doc.js').Doc} ydocPrev
* @param {import('./Doc.js').Doc} ydocNext
*/
export const createContentIdsFromDocDiff = (ydocPrev, ydocNext) =>
excludeContentIds(createContentIdsFromDoc(ydocPrev, true), createContentIdsFromDoc(ydocNext, true))
/**
* @param {ContentIds} content
* @param {ContentIds} excludeContent
*/
export const excludeContentIds = (content, excludeContent) =>
createContentIds(diffIdSet(content.inserts, excludeContent.inserts), diffIdSet(content.deletes, excludeContent.deletes))
/**
* @param {ContentMap} content
* @param {ContentIds | ContentMap} excludeContent
*/
export const excludeContentMap = (content, excludeContent) => createContentMap(
diffIdMap(content.inserts, excludeContent.inserts),
diffIdMap(content.deletes, excludeContent.deletes)
)
/**
* @param {Array<ContentMap>} contents
*/
export const mergeContentMaps = contents => createContentMap(
mergeIdMaps(contents.map(c => c.inserts)),
mergeIdMaps(contents.map(c => c.deletes))
)
/**
* @param {Array<ContentIds>} contents
*/
export const mergeContentIds = contents => createContentIds(
mergeIdSets(contents.map(c => c.inserts)),
mergeIdSets(contents.map(c => c.deletes))
)
/**
* @param {IdMap<any>} [inserts]
* @param {IdMap<any>} [deletes]
* @return {ContentMap}
*/
export const createContentMap = (inserts = createIdMap(), deletes = createIdMap()) => ({ inserts, deletes })
/**
* @param {ContentIds} contentIds
* @param {Array<ContentAttribute<any>>} insertAttrs
* @param {Array<ContentAttribute<any>>} [deleteAttrs]
*/
export const createContentMapFromContentIds = (contentIds, insertAttrs, deleteAttrs = insertAttrs) => createContentMap(
createIdMapFromIdSet(contentIds.inserts, insertAttrs),
createIdMapFromIdSet(contentIds.deletes, deleteAttrs)
)
/**
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
* @param {ContentIds} contentIds
*/
export const writeContentIds = (encoder, contentIds) => {
writeIdSet(encoder, contentIds.inserts)
writeIdSet(encoder, contentIds.deletes)
}
/**
* @param {ContentIds} contentIds
*/
export const encodeContentIds = contentIds => {
const encoder = new IdSetEncoderV2()
writeContentIds(encoder, contentIds)
return encoder.toUint8Array()
}
/**
* @todo this encoding needs to be heavily optimized for production
*
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
* @return {ContentIds}
*/
export const readContentIds = decoder => createContentIds(
readIdSet(decoder),
readIdSet(decoder)
)
/**
* @param {Uint8Array<any>} buf
*/
export const decodeContentIds = buf => readContentIds(new IdSetDecoderV2(decoding.createDecoder(buf)))
/**
* @todo this encoding needs to be heavily optimized for production
*
* @param {import('./UpdateEncoder.js').IdSetEncoder} encoder
* @param {ContentMap} contentMap
*/
export const writeContentMap = (encoder, contentMap) => {
writeIdMap(encoder, contentMap.inserts)
writeIdMap(encoder, contentMap.deletes)
}
/**
* @todo this encoding needs to be heavily optimized for production
*
* @param {import('./UpdateDecoder.js').IdSetDecoder} decoder
* @return {ContentMap} contentMap
*/
export const readContentMap = (decoder) => createContentMap(
readIdMap(decoder),
readIdMap(decoder)
)
/**
* @param {ContentMap} contentMap
*/
export const encodeContentMap = contentMap => {
const encoder = new IdSetEncoderV2()
writeContentMap(encoder, contentMap)
return encoder.toUint8Array()
}
/**
* @param {ContentMap} mapA
* @param {ContentMap|ContentIds} mapB
*/
export const intersectContentMap = (mapA, mapB) => createContentMap(
intersectMaps(mapA.inserts, mapB.inserts),
intersectMaps(mapA.deletes, mapB.deletes)
)
/**
* @param {ContentIds} setA
* @param {ContentIds|ContentMap} setB
*/
export const intersectContentIds = (setA, setB) => createContentIds(
intersectSets(setA.inserts, setB.inserts),
intersectSets(setA.deletes, setB.deletes)
)
/**
* @param {Uint8Array<any>} buf
*/
export const decodeContentMap = buf => readContentMap(new IdSetDecoderV2(decoding.createDecoder(buf)))
/**
* @todo filter by array of content instead
* @param {ContentMap} contentMap
* @param {(c:Array<ContentAttribute<any>>)=>boolean} insertPredicate
* @param {(c:Array<ContentAttribute<any>>)=>boolean} deletePredicate
*/
export const filterContentMap = (contentMap, insertPredicate, deletePredicate) => createContentMap(filterIdMap(contentMap.inserts, insertPredicate), filterIdMap(contentMap.deletes, deletePredicate))
/**
* Schema of {@link ContentIds} - the shape produced by {@link createContentIds}.
*
* Structural: a `ContentIds` is a plain object literal, so there is no prototype to tag. The
* *nominal* {@link $idSet} checks on the two fields are what discriminate it from a
* {@link ContentMap} in both directions.
*
* @type {s.Schema<ContentIds>}
*/
export const $contentIds = s.$object({ inserts: $idSet, deletes: $idSet })
/**
* Schema of {@link ContentMap} - the shape produced by {@link createContentMap}.
*
* See {@link $contentIds}. Use {@link $idMap} on the individual fields to additionally constrain
* the mapped-value type; `ContentMap` itself hardcodes `IdMap<any>`.
*
* @type {s.Schema<ContentMap>}
*/
export const $contentMap = s.$object({ inserts: $idMapAny, deletes: $idMapAny })

View File

@@ -0,0 +1,314 @@
import * as dpos from 'lib0/delta/position'
import * as map from 'lib0/map'
import { createRelativePosition, createAbsolutePositionFromRelativePosition } from './RelativePosition.js'
import { rendererContentLength } from './renderer-helpers.js'
import { getPathTo } from './YEvent.js'
import { isParentOf } from './isParentOf.js'
import { createID, findRootTypeKey } from './ID.js'
import { $doc } from './schemas.js'
import { Item, ContentType, followRedone } from '../structs/Item.js'
/**
* Transform lib0 delta positions ({@link DeltaPosition} — path-based, ephemeral, relative to a
* rendered delta tree) to {@link import('./RelativePosition.js').RelativePosition}s (item-ID-based,
* converging) and back. Use this to persist or sync cursors/marks (e.g. the result of
* `position.marksToPositions(node.delta)`) robustly through Yjs.
*
* Both directions operate on a set of positions and return results 1:1 with the input (`null` for
* positions that can't be resolved). Extra fields of a `MarkPos` (`id`, `attrs`) are ignored — zip
* them back yourself: `marksToPositions(d).map((m, i) => ({ id: m.id, rpos: rposs[i] }))`.
*
* A delta position is only meaningful against the delta produced by a specific renderer. Both
* transforms default to the root node's renderer (`root.useRenderer(..)`) — the same default that
* `node.toDelta()` / `node.delta` use — so positions taken from those deltas resolve correctly
* without options. When the delta was rendered differently (e.g. a doc-rooted `diffDocsToDelta`),
* pass that renderer explicitly.
*
* @module position-helpers
*/
/**
* @typedef {import('lib0/delta/position').Pos} DeltaPosition
*/
/**
* @typedef {import('./Renderer.js').AbstractRenderer} AbstractRenderer
*/
/**
* @param {YNode<any>|Doc} root
* @param {{ renderer?: AbstractRenderer|null }} opts
* @return {AbstractRenderer|null}
*/
const resolveRenderer = (root, opts) => opts.renderer !== undefined ? opts.renderer : ($doc.check(root) ? null : root._renderer)
/**
* The child node at a rendered slot / map entry: node children are always ContentType items.
* Scalars (and subdocs) yield `null` — a delta position cannot descend into them.
*
* @param {Item} item
* @return {YNode<any>|null}
*/
const contentTypeChild = item => item.content.constructor === ContentType ? /** @type {ContentType} */ (item.content).type : null
/**
* The current map entry for `key`, unless it is absent or deleted without being rendered
* (mirrors the renderer-aware map lookup of `applyDelta`).
*
* @param {YNode<any>} node
* @param {string} key
* @param {AbstractRenderer|null} renderer
* @return {Item|null}
*/
const mapItemGet = (node, key, renderer) => {
const it = node._map.get(key)
return it === undefined || (it.deleted && rendererContentLength(renderer, it) === 0) ? null : it
}
/**
* Transform delta positions (paths relative to `root`) to relative positions.
*
* Positions are resolved as a batch: one linked-list walk per visited node serves all positions
* inside it, so many cursors in the same text cost a single traversal.
*
* When `root` is a Doc, paths must start with a root-type name (matching the doc-rooted deltas of
* `diffDocsToDelta`). A terminal string step (attribute leaf, e.g. `['key']`) anchors the map
* entry's item — such a relative position round-trips through this module, but its index is
* meaningless to `createAbsolutePositionFromRelativePosition`. Unresolvable positions map to
* `null`: the empty path `[]` (a node reference doesn't round-trip), descent into scalars or
* subdocs, missing attributes, and out-of-range non-terminal indexes.
*
* @param {YNode<any>|Doc} root
* @param {Array<DeltaPosition>} positions
* @param {{ renderer?: AbstractRenderer|null }} [opts]
* @return {Array<import('./RelativePosition.js').RelativePosition|null>}
*/
export const createRelativePositionsFromDeltaPositions = (root, positions, opts = {}) => {
const renderer = resolveRenderer(root, opts)
/**
* @type {Array<import('./RelativePosition.js').RelativePosition|null>}
*/
const results = new Array(positions.length).fill(null)
/**
* @typedef {{ posIdx: number, pos: DeltaPosition, depth: number }} Task
*/
/**
* @type {Map<YNode<any>, Array<Task>>}
*/
let layer = new Map()
for (let i = 0; i < positions.length; i++) {
const pos = positions[i]
if (pos == null || pos.path.length === 0) continue
if ($doc.check(root)) {
const step0 = pos.path[0]
// doc-rooted paths start with a root-type name; root types are not items, so a terminal
// string at doc level has nothing to anchor
if (typeof step0 !== 'string' || pos.path.length === 1) continue
map.setIfUndefined(layer, root.get(step0), () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 1 })
} else {
map.setIfUndefined(layer, root, () => /** @type {Array<Task>} */ ([])).push({ posIdx: i, pos, depth: 0 })
}
}
while (layer.size > 0) {
/**
* @type {Map<YNode<any>, Array<Task>>}
*/
const nextLayer = new Map()
layer.forEach((tasks, node) => {
/**
* @type {Array<{ eff: number, isLast: boolean, task: Task }>}
*/
const walkTargets = []
for (const task of tasks) {
const { posIdx, pos, depth } = task
const step = pos.path[depth]
const isLast = depth === pos.path.length - 1
if (typeof step === 'string') {
const it = mapItemGet(node, step, renderer)
if (it === null) continue
if (isLast) {
// attribute leaf: anchor the item holding the attribute value
results[posIdx] = createRelativePosition(node, it.lastId, pos.assoc === -1 ? -1 : 0)
} else {
const child = contentTypeChild(it)
if (child !== null) {
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx, pos, depth: depth + 1 })
}
}
} else if (Number.isInteger(step) && step >= 0) {
if (isLast && pos.assoc === -1 && step === 0) {
results[posIdx] = createRelativePosition(node, null, -1)
} else {
// a left-associated cursor gap binds to the preceding character (cf. createRelativePositionFromTypeIndex)
walkTargets.push({ eff: isLast && pos.assoc === -1 ? step - 1 : step, isLast, task })
}
}
}
if (walkTargets.length > 0) {
// one walk over the item list resolves all targets of this node
walkTargets.sort((a, b) => a.eff - b.eff)
let item = node._start
let itemLen = item === null ? 0 : rendererContentLength(renderer, item)
let cum = 0
/**
* @type {Item|null}
*/
let last = null
for (const { eff, isLast, task } of walkTargets) {
while (item !== null && cum + itemLen <= eff) {
cum += itemLen
last = item
item = item.right
itemLen = item === null ? 0 : rendererContentLength(renderer, item)
}
if (item !== null) {
const offset = eff - cum
if (isLast) {
results[task.posIdx] = createRelativePosition(node, createID(item.id.client, item.id.clock + offset), task.pos.assoc === -1 ? -1 : 0)
} else {
const child = contentTypeChild(item)
if (child !== null) {
map.setIfUndefined(nextLayer, child, () => /** @type {Array<Task>} */ ([])).push({ posIdx: task.posIdx, pos: task.pos, depth: task.depth + 1 })
}
}
} else if (isLast) {
// walked past the end — same semantics as createRelativePositionFromTypeIndex
const yassoc = task.pos.assoc === -1 ? -1 : 0
results[task.posIdx] = createRelativePosition(node, yassoc < 0 && last !== null ? last.lastId : null, yassoc)
}
}
}
})
layer = nextLayer
}
return results
}
/**
* @param {YNode<any>} node
* @param {AbstractRenderer|null} renderer
* @return {number}
*/
const renderedLength = (node, renderer) => {
let len = 0
for (let item = node._start; item !== null; item = item.right) {
len += rendererContentLength(renderer, item)
}
return len
}
/**
* Transform relative positions to delta positions (paths relative to `root`).
*
* Resolution is batched: the path from `root` to a node is computed once per distinct node, however
* many positions live inside it. Positions outside of `root`'s subtree (or from another doc) map to
* `null`, as do attribute-leaf anchors whose attribute no longer exists.
*
* `followUndoneDeletions` (default `true`) matches `createAbsolutePositionFromRelativePosition` —
* set it to `false` for consistent results across clients (https://github.com/yjs/yjs/issues/638).
* The path from `root` down to the position's node never follows undone deletions.
*
* @param {YNode<any>|Doc} root
* @param {Array<import('./RelativePosition.js').RelativePosition|null>} rposs - `null` entries (e.g. from the forward transform) map to `null`
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
* @return {Array<DeltaPosition|null>}
*/
export const createDeltaPositionsFromRelativePositions = (root, rposs, opts = {}) => {
const renderer = resolveRenderer(root, opts)
const followUndoneDeletions = opts.followUndoneDeletions ?? true
const isDocRoot = $doc.check(root)
const doc = isDocRoot ? /** @type {Doc} */ (root) : /** @type {YNode<any>} */ (root).doc
/**
* @type {Array<DeltaPosition|null>}
*/
const results = new Array(rposs.length).fill(null)
if (doc === null) return results
const store = doc.store
/**
* @param {YNode<any>} node
* @return {Array<string|number>|null}
*/
const pathFromRoot = node => {
if (isDocRoot) {
if (node.doc !== doc) return null
let top = node
while (top._item !== null) {
top = /** @type {YNode<any>} */ (top._item.parent)
}
return [findRootTypeKey(top), ...getPathTo(top, node, renderer)]
}
if (node !== root && (node._item === null || !isParentOf(/** @type {YNode<any>} */ (root), node._item))) return null
return getPathTo(/** @type {YNode<any>} */ (root), node, renderer)
}
/**
* @type {Map<YNode<any>, Array<string|number>|null>}
*/
const pathCache = new Map()
/**
* @type {Map<YNode<any>, number>}
*/
const renderedLenCache = new Map()
for (let i = 0; i < rposs.length; i++) {
const rpos = rposs[i]
if (rpos == null) continue
/**
* @type {YNode<any>}
*/
let node
/**
* @type {string|number}
*/
let terminal
if (rpos.item !== null) {
const rightID = rpos.item
if (store.getClock(rightID.client) <= rightID.clock) continue
const { item } = followUndoneDeletions ? followRedone(store, rightID) : { item: store.getItem(rightID) }
if (!(item instanceof Item)) continue
if (item.parentSub !== null) {
// attribute leaf (created by createRelativePositionsFromDeltaPositions)
node = /** @type {YNode<any>} */ (item.parent)
if (mapItemGet(node, item.parentSub, renderer) === null) continue
terminal = item.parentSub
} else {
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
if (apos === null) continue
node = apos.type
terminal = apos.index
}
} else {
const apos = createAbsolutePositionFromRelativePosition(rpos, doc, followUndoneDeletions, renderer)
if (apos === null) continue
node = apos.type
// the end-of-type index of createAbsolutePositionFromRelativePosition is the raw length —
// a delta position must index the rendered tree
terminal = renderer !== null && rpos.assoc >= 0
? map.setIfUndefined(renderedLenCache, node, () => renderedLength(apos.type, renderer))
: apos.index
}
const prefix = map.setIfUndefined(pathCache, node, () => pathFromRoot(/** @type {YNode<any>} */ (node)))
if (prefix === null) continue
results[i] = dpos.create(prefix.concat([terminal]), rpos.assoc < 0 ? -1 : 1)
}
return results
}
/**
* Transform a single delta position to a relative position - see {@link createRelativePositionsFromDeltaPositions}.
*
* @param {YNode<any>|Doc} root
* @param {DeltaPosition} pos
* @param {{ renderer?: AbstractRenderer|null }} [opts]
* @return {import('./RelativePosition.js').RelativePosition|null}
*/
export const createRelativePositionFromDeltaPosition = (root, pos, opts) => createRelativePositionsFromDeltaPositions(root, [pos], opts)[0]
/**
* Transform a single relative position to a delta position - see {@link createDeltaPositionsFromRelativePositions}.
*
* @param {YNode<any>|Doc} root
* @param {import('./RelativePosition.js').RelativePosition|null} rpos
* @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts]
* @return {DeltaPosition|null}
*/
export const createDeltaPositionFromRelativePosition = (root, rpos, opts) => createDeltaPositionsFromRelativePositions(root, [rpos], opts)[0]

View File

@@ -0,0 +1,123 @@
import * as s from 'lib0/schema'
import * as error from 'lib0/error'
import { ObservableV2 } from 'lib0/observable'
import { createIdSet } from './ids.js'
export const attributionJsonSchema = s.$object({
insert: s.$array(s.$string).optional,
insertAt: s.$number.optional,
delete: s.$array(s.$string).optional,
deleteAt: s.$number.optional,
format: s.$record(s.$string, s.$array(s.$string)).optional,
formatAt: s.$number.optional
})
/**
* @todo rename this to `insertBy`, `insertAt`, ..
*
* @typedef {s.Unwrap<typeof attributionJsonSchema>} Attribution
*/
/**
* @template T
*/
export class AttributedContent {
/**
* @param {AbstractContent} content
* @param {number} clock
* @param {boolean} deleted
* @param {Array<ContentAttribute<T>> | null} attrs
* @param {0|1|2|3} renderBehavior
*/
constructor (content, clock, deleted, attrs, renderBehavior) {
this.content = content
this.clock = clock
this.deleted = deleted
this.attrs = attrs
this.render = renderBehavior === 0 ? false : (renderBehavior === 1 ? (!deleted || attrs != null) : true)
/**
* Fresh content that was deleted in the same transaction (mode `3`): even in a
* `retainDeletes` render it must produce an *insert* — the consuming state (e.g. the
* maintained `delta` cache) has never seen this content, so there is nothing to retain.
*/
this.fresh = renderBehavior === 3
}
}
/**
* Abstract base class for renderers. A renderer renders Content (with Attributions) to a delta.
*
* Should fire an event when the attributions changed _after_ the original change happens. This
* Event will be used to update the attribution on the current content.
*
* Only items claimed via {@link AbstractRenderer#hasItem} reach the renderer — everything else is
* rendered by the generic fast path (as-is, without attributions, deleted content invisible).
*
* @extends {ObservableV2<{change:(idset:IdSet,origin:any,local:boolean)=>void}>}
*/
export class AbstractRenderer extends ObservableV2 {
constructor () {
super()
/**
* Ids of the content this renderer may render non-normally (attributed, restored, hidden, …).
* May over-approximate — {@link AbstractRenderer#readContent} remains authoritative for what
* is actually rendered. Content outside this set is rendered by the generic fast path without
* consulting the renderer.
*
* @type {IdSet}
*/
this.attributed = createIdSet()
}
/**
* Whether `item` (any part of its id range) must be rendered by this renderer. Items for which
* this returns `false` are rendered by the generic fast path.
*
* @param {Item} item
* @return {boolean}
*/
hasItem (item) {
return this.attributed.intersects(item.id.client, item.id.clock, item.length)
}
/**
* @param {Array<AttributedContent<any>>} _contents - where to write the result
* @param {number} _client
* @param {number} _clock
* @param {boolean} _deleted
* @param {AbstractContent} _content
* @param {0|1|2|3} _shouldRender - 0: if undeleted or attributed, render as a retain operation. 1: render only if undeleted or attributed. 2: render as insert operation (if unattributed and deleted, render as delete). 3: fresh content deleted in the same transaction, marked {@link AttributedContent#fresh} — render as insert where attributed (it must insert even in a `retainDeletes` render: the consuming state has never seen it), as *nothing* where unattributed (invisible; a delete op would misapply).
*/
readContent (_contents, _client, _clock, _deleted, _content, _shouldRender) {
error.methodUnimplemented()
}
/**
* Calculate the length of the attributed content. This is used by iterators that walk through the
* content.
*
* If the content is not countable, it should return 0.
*
* @param {Item} _item
* @return {number}
*/
contentLength (_item) {
error.methodUnimplemented()
}
}
export const $renderer = AbstractRenderer.prototype.$type = s.$type('y:renderer', AbstractRenderer)
/**
* Rendered length of `item` under `renderer`: the generic rule — alive countable content renders
* at full length, everything else at length `0` — unless the renderer claims the item.
*
* @param {AbstractRenderer?} renderer
* @param {Item} item
* @return {number}
*/
export const rendererContentLength = (renderer, item) =>
renderer !== null && renderer.hasItem(item)
? renderer.contentLength(item)
: ((item.deleted || !item.content.isCountable()) ? 0 : item.length)

3
src/utils/schemas.js Normal file
View File

@@ -0,0 +1,3 @@
import * as s from 'lib0/schema'
export const $doc = /** @type {s.Schema<Doc>} */ (s.$type('y:doc', null))

View File

@@ -0,0 +1,413 @@
import * as math from 'lib0/math'
import * as error from 'lib0/error'
import * as map from 'lib0/map'
import * as set from 'lib0/set'
import { createID } from './ID.js'
/**
* These modules don't require any imports.
* These helpers are used by items to integrate themselves
*/
/**
* Perform a binary search on a sorted array
* @param {Array<Item|GC|Skip>} 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()
}
/**
* @param {Transaction?} transaction
* @param {Array<Item|GC|Skip>} structs
* @param {number} clock
*/
export const findIndexCleanStart = (transaction, structs, clock) => {
const index = findIndexSS(structs, clock)
const struct = structs[index]
if (struct.id.clock < clock) {
structs.splice(index + 1, 0, splitStruct(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) => {
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.isItem) {
structs.splice(index + 1, 0, /** @type {Item} */ (struct).split(transaction, id.clock - struct.id.clock + 1))
}
return /** @type {Item} */ (struct)
}
/**
* Replace `item` with `newitem` in store
* @param {Transaction} tr
* @param {GC|Item} struct
* @param {GC|Item} newStruct
*
* @private
* @function
*/
export const replaceStruct = (tr, struct, newStruct) => {
const structs = /** @type {Array<GC|Item>} */ (tr.doc.store.clients.get(struct.id.client))
structs[findIndexSS(structs, struct.id.clock)] = newStruct
tr._mergeStructs.push(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)
}
/**
* Iterate over a range of structs without splitting the structs. You will get content that you did
* not expect.
*
* @param {Array<Item|GC>} structs
* @param {number} clockStart Inclusive start
* @param {number} len
* @param {(struct: GC|Item|Skip, offset:number, len:number)=>void} f
*
* @function
*/
export const iterateStructsWithoutSplits = (structs, clockStart, len, f) => {
if (len === 0) {
return
}
const clockEnd = clockStart + len
for (
let index = findIndexSS(structs, clockStart), struct = structs[index], clock = struct.id.clock;
clock < clockEnd;
struct = structs[++index], clock = struct.id.clock
) {
const offset = clock < clockStart ? clockStart - clock : 0
f(struct, offset, math.min(clock + struct.length, clockEnd) - clock)
if (index + 1 === structs.length) break
}
}
/**
* More generalized version of splitItem. Split leftStruct into two structs
* @param {Transaction?} transaction
* @param {Item|GC|Skip} leftStruct
* @param {number} diff
* @return {GC|Item|Skip}
*
* @function
* @private
*/
export const splitStruct = (transaction, leftStruct, diff) => {
if (leftStruct.isItem) {
return /** @type {Item} */ (leftStruct).split(transaction, diff)
} else {
const rightItem = leftStruct.splice(diff)
transaction?._mergeStructs.push(rightItem)
return rightItem
}
}
/**
* @param {Transaction} transaction
*
* @private
* @function
*/
export const nextID = transaction => {
const y = transaction.doc
return createID(y.clientID, y.store.getClock(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 {YNode} type
* @param {string|null} parentSub
*/
export const addChangedTypeToTransaction = (transaction, type, parentSub) => {
const item = type._item
if (item === null || !transaction.insertSet.hasId(item.id)) {
map.setIfUndefined(transaction.changed, type, set.create).add(parentSub)
}
}
/**
* @param {Array<GC | Item | Skip>} structs
* @param {number} pos
* @return {number} # of merged structs
*/
export 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(/** @type {any} */ (right))) {
if (right.isItem && /** @type {Item} */ (right).parentSub !== null && /** @type {YNode} */ (/** @type {Item} */ (right).parent)._map.get(/** @type {Item} */ (right).parentSub) === right) {
/** @type {YNode} */ (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 {Transaction} tr
* @param {IdSet} ds
* @param {function(Item):boolean} gcFilter
*/
export const tryGcDeleteSet = (tr, ds, gcFilter) => {
for (const [client, _deleteItems] of ds.clients.entries()) {
const deleteItems = _deleteItems.getIds()
const structs = /** @type {Array<Item>} */ (tr.doc.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.isItem && struct.deleted && !(struct).keep && gcFilter(/** @type {Item} */ (struct))) {
/** @type {Item} */ (struct).gc(tr, false)
}
}
}
}
}
/**
* @param {IdSet} ds
* @param {StructStore} store
*/
export const tryMerge = (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 deleteItems = _deleteItems.getIds()
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 {Transaction} tr
* @param {IdSet} idset
* @param {function(Item):boolean} gcFilter
*/
export const tryGc = (tr, idset, gcFilter) => {
tryGcDeleteSet(tr, idset, gcFilter)
tryMerge(idset, tr.doc.store)
}
/**
* @param {Transaction} transaction
* @param {Item | null} item
*/
export const cleanupContextlessFormattingGap = (transaction, item) => {
if (!transaction.doc.cleanupFormatting) return 0
// iterate until item.right is null or content
while (item && item.right && (item.right.deleted || !item.right.countable)) {
item = item.right
}
const attrs = new Set()
// iterate back until a content item is found
while (item && (item.deleted || !item.countable)) {
if (!item.deleted && item.content.getRef() === 6) { // is a ContentFormat
const key = /** @type {ContentFormat} */ (item.content).key
if (attrs.has(key)) {
item.delete(transaction)
transaction.cleanUps.add(item.id.client, item.id.clock, item.length)
} else {
attrs.add(key)
}
}
item = item.left
}
}
/**
* @param {Map<string,any>} currentFormats
* @param {ContentFormat} format
*
* @private
* @function
*/
export const updateCurrentFormats = (currentFormats, { key, value }) => {
if (value === null) {
currentFormats.delete(key)
} else {
currentFormats.set(key, value)
}
}
/**
* Call this function after string content has been deleted in order to
* clean up formatting Items.
*
* @param {Transaction} transaction
* @param {Item} start
* @param {Item|null} curr exclusive end, automatically iterates to the next Content Item
* @param {Map<string,any>} startFormats
* @param {Map<string,any>} currFormats
* @return {number} The amount of formatting Items deleted.
*
* @function
*/
export const cleanupFormattingGap = (transaction, start, curr, startFormats, currFormats) => {
if (!transaction.doc.cleanupFormatting) return 0
/**
* @type {Item|null}
*/
let end = start
/**
* @type {Map<string,ContentFormat>}
*/
const endFormats = map.create()
while (end && (!end.countable || end.deleted)) {
if (!end.deleted && end.content.getRef() === 6) {
const cf = /** @type {ContentFormat} */ (end.content)
endFormats.set(cf.key, cf)
}
end = end.right
}
let cleanups = 0
let reachedCurr = false
while (start !== end) {
if (curr === start) {
reachedCurr = true
}
if (!start.deleted) {
const content = start.content
if (content.getRef() === 6) { // is ContentFormat
const { key, value } = /** @type {ContentFormat} */ (content)
const startFormatValue = startFormats.get(key) ?? null
if (endFormats.get(key) !== content || startFormatValue === value) {
// Either this format is overwritten or it is not necessary because the format already existed.
start.delete(transaction)
transaction.cleanUps.add(start.id.client, start.id.clock, start.length)
cleanups++
if (!reachedCurr && (currFormats.get(key) ?? null) === value && startFormatValue !== value) {
if (startFormatValue === null) {
currFormats.delete(key)
} else {
currFormats.set(key, startFormatValue)
}
}
}
if (!reachedCurr && !start.deleted) {
updateCurrentFormats(currFormats, /** @type {ContentFormat} */ (content))
}
}
}
start = /** @type {Item} */ (start.right)
}
return cleanups
}

576
src/utils/updates.js Normal file
View File

@@ -0,0 +1,576 @@
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 string from 'lib0/string'
import { readIdSet, writeIdSet, createIdSet, intersectSets } from './ids.js'
import { createID } from './ID.js'
import { IdSetEncoderV1, IdSetEncoderV2, UpdateEncoderV1, UpdateEncoderV2 } from './UpdateEncoder.js'
import { UpdateDecoderV1, UpdateDecoderV2 } from './UpdateDecoder.js'
import { GC } from '../structs/GC.js'
import {
Item,
ContentAny,
ContentBinary,
ContentDeleted,
ContentDoc,
ContentEmbed,
ContentFormat,
ContentJSON,
ContentString,
ContentType
} from '../structs/Item.js'
import {
readItemContent
} from '../ynode.js'
import { Skip } from '../structs/Skip.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 = readIdSet(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: readIdSet(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 {Uint8Array} update
* @param {typeof IdSetEncoderV1 | typeof IdSetEncoderV2} YEncoder
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} YDecoder
* @return {Uint8Array<ArrayBuffer>}
*/
export const encodeStateVectorFromUpdateV2 = (update, YEncoder = IdSetEncoderV2, 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<ArrayBuffer>}
*/
export const encodeStateVectorFromUpdate = update => encodeStateVectorFromUpdateV2(update, IdSetEncoderV1, UpdateDecoderV1)
/**
* @param {Uint8Array} update
* @param {typeof UpdateDecoderV2 | typeof UpdateDecoderV1} [YDecoder]
* @return {ContentIds}
*/
export const createContentIdsFromUpdateV2 = (update, YDecoder = UpdateDecoderV2) => {
const updateDecoder = new YDecoder(decoding.createDecoder(update))
const lazyDecoder = new LazyStructReader(updateDecoder, true)
const inserts = createIdSet()
let lastClientId = -1
let lastClock = 0
let lastLen = 0
for (let curr = lazyDecoder.curr; curr !== null; curr = lazyDecoder.next()) {
const currId = curr.id
if (lastClientId === currId.client && lastClock + lastLen === currId.clock) {
// default case: extend prev entry
lastLen += curr.length
} else {
if (lastClientId >= 0) {
inserts.add(lastClientId, lastClock, lastLen)
}
lastClientId = currId.client
lastClock = currId.clock
lastLen = curr.length
}
}
if (lastClientId >= 0) {
inserts.add(lastClientId, lastClock, lastLen)
}
const deletes = readIdSet(updateDecoder)
return { inserts, deletes }
}
/**
* @param {Uint8Array} update
* @return {ContentIds}
*/
export const createContentIdsFromUpdate = update => createContentIdsFromUpdateV2(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 | Skip}
*/
export 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)
)
}
}
/**
* @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 | Skip} struct
* @param {number} offset
* @param {number} offsetEnd
*/
export const writeStructToLazyStructWriter = (lazyWriter, struct, offset, offsetEnd) => {
// 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, offsetEnd)
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
*/
export 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, 0)
}
finishLazyStructWriting(lazyWriter)
const ds = readIdSet(updateDecoder)
writeIdSet(updateEncoder, ds)
return updateEncoder.toUint8Array()
}
/**
* @typedef {Object} ObfuscatorOptions
* @property {boolean} [ObfuscatorOptions.formatting=true]
* @property {boolean} [ObfuscatorOptions.subdocs=true]
* @property {boolean} [ObfuscatorOptions.name=true] Whether to obfuscate nodeName / hookName
*/
/**
* @param {ObfuscatorOptions} obfuscator
*/
const createObfuscator = ({ formatting = true, subdocs = true, name = 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 (name) {
const type = /** @type {ContentType} */ (content).type
if (type.name != null) {
type.name = map.setIfUndefined(nodeNameCache, type.name, () => 'typename-' + 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.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)
/**
* Filter an update to only include content specified by a ContentIds pattern.
*
* This function extracts a subset of an update, keeping only the structs whose IDs
* are present in `contentIds.inserts` and only the delete set entries that are
* present in `contentIds.deletes`.
*
* Note: If a struct partially overlaps with the contentIds pattern, only the
* overlapping portion is included in the result.
*
* @param {Uint8Array} update
* @param {ContentIds} contentIds - Pattern specifying which content to include
* @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder]
* @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder]
* @return {Uint8Array<ArrayBuffer>}
*/
export const intersectUpdateWithContentIdsV2 = (update, contentIds, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => {
const { inserts, deletes } = contentIds
const encoder = new YEncoder()
const lazyStructWriter = new LazyStructWriter(encoder)
const decoder = new YDecoder(decoding.createDecoder(update))
const reader = new LazyStructReader(decoder, true)
while (reader.curr) {
const currClientId = reader.curr.id.client
let nextClock = reader.curr.id.clock
let firstWrite = false
while (reader.curr != null && reader.curr.id.client === currClientId) {
const curr = reader.curr
for (const slice of inserts.slice(currClientId, curr.id.clock, curr.length)) {
if (slice.exists) {
const skipLen = slice.clock - nextClock
if (skipLen > 0 && firstWrite) {
// write missing skip
writeStructToLazyStructWriter(lazyStructWriter, new Skip(createID(currClientId, nextClock), skipLen), 0, 0)
}
// write sliced content
writeStructToLazyStructWriter(lazyStructWriter, curr, slice.clock - curr.id.clock, (curr.id.clock + curr.length) - (slice.clock + slice.len))
nextClock = slice.clock + slice.len
firstWrite = true
}
}
reader.next()
}
}
finishLazyStructWriting(lazyStructWriter)
// Filter the delete set to only include entries in contentIds.deletes
const ds = readIdSet(decoder)
const filteredDs = intersectSets(ds, deletes)
writeIdSet(encoder, filteredDs)
return encoder.toUint8Array()
}
/**
* Filter an update (V1 format) to only include content specified by a ContentIds pattern.
*
* @param {Uint8Array} update
* @param {ContentIds} contentIds - Pattern specifying which content to include
* @return {Uint8Array<ArrayBuffer>}
*/
export const intersectUpdateWithContentIds = (update, contentIds) =>
intersectUpdateWithContentIdsV2(update, contentIds, UpdateDecoderV1, UpdateEncoderV1)

2824
src/ynode.js Normal file

File diff suppressed because it is too large Load Diff

176
test.html Normal file
View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html>
<head>
<title>Testing @y/y</title>
<script type="importmap">
{
"imports": {
"@y/y": "./src/index.js",
"@y/y/meta": "./src/utils/meta.js",
"@y/y/testHelper": "./tests/testHelper.js",
"@y/y/package.json": "./package.json",
"lib0/package.json": "./node_modules/lib0/package.json",
"lib0/array": "./node_modules/lib0/src/array.js",
"lib0/binary": "./node_modules/lib0/src/binary.js",
"lib0/broadcastchannel": "./node_modules/lib0/src/broadcastchannel.js",
"lib0/buffer": "./node_modules/lib0/src/buffer.js",
"lib0/cache": "./node_modules/lib0/src/cache.js",
"lib0/conditions": "./node_modules/lib0/src/conditions.js",
"lib0/crypto/jwt": "./node_modules/lib0/src/crypto/jwt.js",
"lib0/crypto/aes-gcm": "./node_modules/lib0/src/crypto/aes-gcm.js",
"lib0/delta": "./node_modules/lib0/src/delta/delta.js",
"lib0/delta/transformer": "./node_modules/lib0/src/delta/transformer.js",
"lib0/delta/position": "./node_modules/lib0/src/delta/position.js",
"lib0/delta/transformer/inline": "./node_modules/lib0/src/delta/transformer/inline.js",
"lib0/delta/transformer/children": "./node_modules/lib0/src/delta/transformer/children.js",
"lib0/delta/transformer/id": "./node_modules/lib0/src/delta/transformer/id.js",
"lib0/delta/transformer/rename": "./node_modules/lib0/src/delta/transformer/rename.js",
"lib0/delta/transformer/filter": "./node_modules/lib0/src/delta/transformer/filter.js",
"lib0/delta/transformer/pipe": "./node_modules/lib0/src/delta/transformer/pipe.js",
"lib0/delta/transformer/attr": "./node_modules/lib0/src/delta/transformer/attr.js",
"lib0/delta/transformer/project": "./node_modules/lib0/src/delta/transformer/project.js",
"lib0/delta/transformer/value": "./node_modules/lib0/src/delta/transformer/value.js",
"lib0/delta/transformer/rename-attrs": "./node_modules/lib0/src/delta/transformer/rename-attrs.js",
"lib0/delta/transformer/attribution-to-format": "./node_modules/lib0/src/delta/transformer/attribution-to-format.js",
"lib0/delta/transformer/full-attributions": "./node_modules/lib0/src/delta/transformer/full-attributions.js",
"lib0/delta/rdt": "./node_modules/lib0/src/delta/rdt.js",
"lib0/delta/rdt/delta": "./node_modules/lib0/src/delta/rdt/delta.js",
"lib0/delta/rdt/dom": "./node_modules/lib0/src/delta/rdt/dom.js",
"lib0/crypto/ecdsa": "./node_modules/lib0/src/crypto/ecdsa.js",
"lib0/crypto/rsa-oaep": "./node_modules/lib0/src/crypto/rsa-oaep.js",
"lib0/hash/fnv1a": "./node_modules/lib0/src/hash/fnv1a.js",
"lib0/hash/rabin": "./node_modules/lib0/src/hash/rabin.js",
"lib0/hash/sha256": "./node_modules/lib0/src/hash/sha256.js",
"lib0/decoding": "./node_modules/lib0/src/decoding.js",
"lib0/diff/patience": "./node_modules/lib0/src/diff/patience.js",
"lib0/diff": "./node_modules/lib0/src/diff.js",
"lib0/dom": "./node_modules/lib0/src/dom.js",
"lib0/encoding": "./node_modules/lib0/src/encoding.js",
"lib0/environment": "./node_modules/lib0/src/environment.js",
"lib0/error": "./node_modules/lib0/src/error.js",
"lib0/eventloop": "./node_modules/lib0/src/eventloop.js",
"lib0/function": "./node_modules/lib0/src/function.js",
"lib0/indexeddb": "./node_modules/lib0/src/indexeddb.js",
"lib0/iterator": "./node_modules/lib0/src/iterator.js",
"lib0/json": "./node_modules/lib0/src/json.js",
"lib0/list": "./node_modules/lib0/src/list.js",
"lib0/logging": "./node_modules/lib0/src/logging.js",
"lib0/map": "./node_modules/lib0/src/map.js",
"lib0/math": "./node_modules/lib0/src/math.js",
"lib0/metric": "./node_modules/lib0/src/metric.js",
"lib0/mutex": "./node_modules/lib0/src/mutex.js",
"lib0/number": "./node_modules/lib0/src/number.js",
"lib0/object": "./node_modules/lib0/src/object.js",
"lib0/observable": "./node_modules/lib0/src/observable.js",
"lib0/pair": "./node_modules/lib0/src/pair.js",
"lib0/prng": "./node_modules/lib0/src/prng.js",
"lib0/promise": "./node_modules/lib0/src/promise.js",
"lib0/queue": "./node_modules/lib0/src/queue.js",
"lib0/random": "./node_modules/lib0/src/random.js",
"lib0/set": "./node_modules/lib0/src/set.js",
"lib0/sort": "./node_modules/lib0/src/sort.js",
"lib0/statistics": "./node_modules/lib0/src/statistics.js",
"lib0/storage": "./node_modules/lib0/src/storage.js",
"lib0/string": "./node_modules/lib0/src/string.js",
"lib0/symbol": "./node_modules/lib0/src/symbol.js",
"lib0/traits": "./node_modules/lib0/src/trait/traits.js",
"lib0/trait/fingerprint": "./node_modules/lib0/src/trait/fingerprint.js",
"lib0/testing": "./node_modules/lib0/src/testing.js",
"lib0/time": "./node_modules/lib0/src/time.js",
"lib0/url": "./node_modules/lib0/src/url.js",
"lib0/websocket": "./node_modules/lib0/src/websocket.js",
"lib0/webcrypto": "./node_modules/lib0/src/webcrypto.js",
"lib0/ts": "./node_modules/lib0/src/ts.js",
"lib0/performance": "./node_modules/lib0/src/performance.js",
"lib0/schema": "./node_modules/lib0/src/schema.js",
"@y/protocols/sync": "./node_modules/@y/protocols/src/sync.js",
"@y/protocols/awareness": "./node_modules/@y/protocols/src/awareness.js",
"@y/protocols/auth": "./node_modules/@y/protocols/src/auth.js"
},
"scopes": {
"./node_modules/@y/protocols/": {
"lib0/package.json": "./node_modules/lib0/package.json",
"lib0/array": "./node_modules/lib0/src/array.js",
"lib0/binary": "./node_modules/lib0/src/binary.js",
"lib0/broadcastchannel": "./node_modules/lib0/src/broadcastchannel.js",
"lib0/buffer": "./node_modules/lib0/src/buffer.js",
"lib0/cache": "./node_modules/lib0/src/cache.js",
"lib0/conditions": "./node_modules/lib0/src/conditions.js",
"lib0/crypto/jwt": "./node_modules/lib0/src/crypto/jwt.js",
"lib0/crypto/aes-gcm": "./node_modules/lib0/src/crypto/aes-gcm.js",
"lib0/delta": "./node_modules/lib0/src/delta/delta.js",
"lib0/delta/transformer": "./node_modules/lib0/src/delta/transformer.js",
"lib0/delta/position": "./node_modules/lib0/src/delta/position.js",
"lib0/delta/transformer/inline": "./node_modules/lib0/src/delta/transformer/inline.js",
"lib0/delta/transformer/children": "./node_modules/lib0/src/delta/transformer/children.js",
"lib0/delta/transformer/id": "./node_modules/lib0/src/delta/transformer/id.js",
"lib0/delta/transformer/rename": "./node_modules/lib0/src/delta/transformer/rename.js",
"lib0/delta/transformer/filter": "./node_modules/lib0/src/delta/transformer/filter.js",
"lib0/delta/transformer/pipe": "./node_modules/lib0/src/delta/transformer/pipe.js",
"lib0/delta/transformer/attr": "./node_modules/lib0/src/delta/transformer/attr.js",
"lib0/delta/transformer/project": "./node_modules/lib0/src/delta/transformer/project.js",
"lib0/delta/transformer/value": "./node_modules/lib0/src/delta/transformer/value.js",
"lib0/delta/transformer/rename-attrs": "./node_modules/lib0/src/delta/transformer/rename-attrs.js",
"lib0/delta/transformer/attribution-to-format": "./node_modules/lib0/src/delta/transformer/attribution-to-format.js",
"lib0/delta/transformer/full-attributions": "./node_modules/lib0/src/delta/transformer/full-attributions.js",
"lib0/delta/rdt": "./node_modules/lib0/src/delta/rdt.js",
"lib0/delta/rdt/delta": "./node_modules/lib0/src/delta/rdt/delta.js",
"lib0/delta/rdt/dom": "./node_modules/lib0/src/delta/rdt/dom.js",
"lib0/crypto/ecdsa": "./node_modules/lib0/src/crypto/ecdsa.js",
"lib0/crypto/rsa-oaep": "./node_modules/lib0/src/crypto/rsa-oaep.js",
"lib0/hash/fnv1a": "./node_modules/lib0/src/hash/fnv1a.js",
"lib0/hash/rabin": "./node_modules/lib0/src/hash/rabin.js",
"lib0/hash/sha256": "./node_modules/lib0/src/hash/sha256.js",
"lib0/decoding": "./node_modules/lib0/src/decoding.js",
"lib0/diff/patience": "./node_modules/lib0/src/diff/patience.js",
"lib0/diff": "./node_modules/lib0/src/diff.js",
"lib0/dom": "./node_modules/lib0/src/dom.js",
"lib0/encoding": "./node_modules/lib0/src/encoding.js",
"lib0/environment": "./node_modules/lib0/src/environment.js",
"lib0/error": "./node_modules/lib0/src/error.js",
"lib0/eventloop": "./node_modules/lib0/src/eventloop.js",
"lib0/function": "./node_modules/lib0/src/function.js",
"lib0/indexeddb": "./node_modules/lib0/src/indexeddb.js",
"lib0/iterator": "./node_modules/lib0/src/iterator.js",
"lib0/json": "./node_modules/lib0/src/json.js",
"lib0/list": "./node_modules/lib0/src/list.js",
"lib0/logging": "./node_modules/lib0/src/logging.js",
"lib0/map": "./node_modules/lib0/src/map.js",
"lib0/math": "./node_modules/lib0/src/math.js",
"lib0/metric": "./node_modules/lib0/src/metric.js",
"lib0/mutex": "./node_modules/lib0/src/mutex.js",
"lib0/number": "./node_modules/lib0/src/number.js",
"lib0/object": "./node_modules/lib0/src/object.js",
"lib0/observable": "./node_modules/lib0/src/observable.js",
"lib0/pair": "./node_modules/lib0/src/pair.js",
"lib0/prng": "./node_modules/lib0/src/prng.js",
"lib0/promise": "./node_modules/lib0/src/promise.js",
"lib0/queue": "./node_modules/lib0/src/queue.js",
"lib0/random": "./node_modules/lib0/src/random.js",
"lib0/set": "./node_modules/lib0/src/set.js",
"lib0/sort": "./node_modules/lib0/src/sort.js",
"lib0/statistics": "./node_modules/lib0/src/statistics.js",
"lib0/storage": "./node_modules/lib0/src/storage.js",
"lib0/string": "./node_modules/lib0/src/string.js",
"lib0/symbol": "./node_modules/lib0/src/symbol.js",
"lib0/traits": "./node_modules/lib0/src/trait/traits.js",
"lib0/trait/fingerprint": "./node_modules/lib0/src/trait/fingerprint.js",
"lib0/testing": "./node_modules/lib0/src/testing.js",
"lib0/time": "./node_modules/lib0/src/time.js",
"lib0/url": "./node_modules/lib0/src/url.js",
"lib0/websocket": "./node_modules/lib0/src/websocket.js",
"lib0/webcrypto": "./node_modules/lib0/src/webcrypto.js",
"lib0/ts": "./node_modules/lib0/src/ts.js",
"lib0/performance": "./node_modules/lib0/src/performance.js",
"lib0/schema": "./node_modules/lib0/src/schema.js"
}
}
}
</script>
</head>
<body>
<script type="module" src="./tests/index.js"></script>
</body>
</html>

236
tests/IdMap.tests.js Normal file
View File

@@ -0,0 +1,236 @@
import * as t from 'lib0/testing'
import * as ids from '../src/utils/ids.js'
import * as prng from 'lib0/prng'
import * as math from 'lib0/math'
import { compareIdmaps as compareIdMaps, createIdMap, ID, createRandomIdSet, createRandomIdMap, createContentAttribute } from './testHelper.js'
import * as YY from '../src/index.js'
import * as time from 'lib0/time'
/**
* @template T
* @param {Array<[number, number, number, Array<T>]>} ops
*/
const simpleConstructAttrs = ops => {
const attrs = createIdMap()
ops.forEach(op => {
attrs.add(op[0], op[1], op[2], op[3].map(v => createContentAttribute('', v)))
})
return attrs
}
/**
* @param {t.TestCase} _tc
*/
export const testAmMerge = _tc => {
const attrs = [42]
t.group('filter out empty items (1))', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 0, attrs]]),
simpleConstructAttrs([])
)
})
t.group('filter out empty items (2))', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 0, attrs]]),
simpleConstructAttrs([])
)
})
t.group('filter out empty items (3 - end))', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs]]),
simpleConstructAttrs([[0, 1, 1, attrs]])
)
})
t.group('filter out empty items (4 - middle))', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 1, attrs], [0, 2, 0, attrs], [0, 3, 1, attrs]]),
simpleConstructAttrs([[0, 1, 1, attrs], [0, 3, 1, attrs]])
)
})
t.group('filter out empty items (5 - beginning))', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 0, attrs], [0, 2, 1, attrs], [0, 3, 1, attrs]]),
simpleConstructAttrs([[0, 2, 1, attrs], [0, 3, 1, attrs]])
)
})
t.group('merge of overlapping id ranges', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 2, attrs], [0, 0, 2, attrs]]),
simpleConstructAttrs([[0, 0, 3, attrs]])
)
})
t.group('construct without hole', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 2, attrs], [0, 3, 1, attrs]]),
simpleConstructAttrs([[0, 1, 3, attrs]])
)
})
t.group('no merge of overlapping id ranges with different attributes', () => {
compareIdMaps(
simpleConstructAttrs([[0, 1, 2, [1]], [0, 0, 2, [2]]]),
simpleConstructAttrs([[0, 0, 1, [2]], [0, 1, 1, [1, 2]], [0, 2, 1, [1]]])
)
})
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatMergingMultipleIdMaps = tc => {
const clients = 4
const clockRange = 5
/**
* @type {Array<IdMap<number>>}
*/
const sets = []
for (let i = 0; i < 3; i++) {
sets.push(createRandomIdMap(tc.prng, clients, clockRange, [1, 2, 3]))
}
const merged = ids.mergeIdMaps(sets)
const mergedReverse = ids.mergeIdMaps(sets.reverse())
compareIdMaps(merged, mergedReverse)
const composed = ids.createIdMap()
for (let iclient = 0; iclient < clients; iclient++) {
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
const mergedHas = merged.hasId(new ID(iclient, iclock))
const oneHas = sets.some(ids => ids.hasId(new ID(iclient, iclock)))
t.assert(mergedHas === oneHas)
const mergedAttrs = merged.sliceId(new ID(iclient, iclock), 1)
mergedAttrs.forEach(a => {
if (a.attrs != null) {
composed.add(iclient, a.clock, a.len, a.attrs)
}
})
}
}
compareIdMaps(merged, composed)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDiffing = tc => {
const clients = 4
const clockRange = 100
const attrs = [1, 2, 3]
const idset1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
const idset2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
const merged = ids.mergeIdMaps([idset1, idset2])
const e1 = ids.diffIdMap(idset1, idset2)
const e2 = ids.diffIdMap(merged, idset2)
compareIdMaps(e1, e2)
const copy = YY.decodeIdMap(YY.encodeIdMap(e1))
compareIdMaps(e1, copy)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDiffing2 = tc => {
const clients = 4
const clockRange = 100
const attrs = [1, 2, 3]
const idmap1 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
const idmap2 = createRandomIdMap(tc.prng, clients, clockRange, attrs)
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
const merged = ids.mergeIdMaps([idmap1, idmap2])
const mergedExcluded = ids.diffIdMap(merged, idsExclude)
const e1 = ids.diffIdMap(idmap1, idsExclude)
const e2 = ids.diffIdMap(idmap2, idsExclude)
const excludedMerged = ids.mergeIdMaps([e1, e2])
compareIdMaps(mergedExcluded, excludedMerged)
const copy = YY.decodeIdMap(YY.encodeIdMap(mergedExcluded))
compareIdMaps(mergedExcluded, copy)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDeletes = tc => {
const clients = 1
const clockRange = 100
const idset = createRandomIdMap(tc.prng, clients, clockRange, [])
const client = Array.from(idset.clients.keys())[0]
const clock = prng.int31(tc.prng, 0, clockRange)
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
const idsetOfDeletes = ids.createIdMap()
idsetOfDeletes.add(client, clock, len, [])
const diffed = ids.diffIdMap(idset, idsetOfDeletes)
idset.delete(client, clock, len)
for (let i = 0; i < len; i++) {
t.assert(!idset.has(client, clock + i))
}
compareIdMaps(idset, diffed)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomIntersects = tc => {
const clients = 4
const clockRange = 100
const ids1 = createRandomIdMap(tc.prng, clients, clockRange, [1])
const ids2 = createRandomIdMap(tc.prng, clients, clockRange, ['two'])
const intersected = ids.intersectMaps(ids1, ids2)
for (let client = 0; client < clients; client++) {
for (let clock = 0; clock < clockRange; clock++) {
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
/**
* @type {Array<any>?}
*/
const slice1 = ids1.slice(client, clock, 1)[0].attrs
/**
* @type {Array<any>?}
*/
const slice2 = ids2.slice(client, clock, 1)[0].attrs
/**
* @type {Array<any>?}
*/
const expectedAttrs = (slice1 != null && slice2 != null) ? slice1.concat(slice2) : null
const attrs = intersected.slice(client, clock, 1)[0].attrs
t.assert(attrs?.length === expectedAttrs?.length)
}
}
const diffed1 = ids.diffIdMap(ids1, ids2)
const altDiffed1 = ids.diffIdMap(ids1, intersected)
compareIdMaps(diffed1, altDiffed1)
}
/**
* @param {t.TestCase} tc
*/
export const testUserAttributionEncodingBenchmark = tc => {
/**
* @todo debug why this approach needs 30 bytes per item
* @todo it should be possible to only use a single idmap and, in each attr entry, encode the diff
* to the previous entries (e.g. remove a,b, insert c,d)
*/
const attributions = createIdMap()
const currentTime = time.getUnixTime()
const ydoc = new YY.Doc()
ydoc.on('afterTransaction', tr => {
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.insertSet, [createContentAttribute('insert', 'userX'), createContentAttribute('insertAt', currentTime)]))
ids.insertIntoIdMap(attributions, ids.createIdMapFromIdSet(tr.deleteSet, [createContentAttribute('delete', 'userX'), createContentAttribute('deleteAt', currentTime)]))
})
const ytext = ydoc.get()
const N = 10000
t.measureTime(`time to attribute ${N / 1000}k changes`, () => {
for (let i = 0; i < N; i++) {
if (i % 2 > 0 && ytext.length > 0) {
const pos = prng.int31(tc.prng, 0, ytext.length)
const delLen = prng.int31(tc.prng, 0, ytext.length - pos)
ytext.delete(pos, delLen)
} else {
ytext.insert(prng.int31(tc.prng, 0, ytext.length), prng.word(tc.prng))
}
}
})
t.measureTime('time to encode attributions map', () => {
/**
* @todo I can optimize size by encoding only the differences to the prev item.
*/
const encAttributions = ids.encodeIdMap(attributions)
t.info('encoded size: ' + encAttributions.byteLength)
t.info('size per change: ' + math.floor((encAttributions.byteLength / N) * 100) / 100 + ' bytes')
})
}

397
tests/IdSet.tests.js Normal file
View File

@@ -0,0 +1,397 @@
import * as t from 'lib0/testing'
import * as Y from '../src/index.js'
import * as d from '../src/utils/ids.js'
import * as math from 'lib0/math'
import * as prng from 'lib0/prng'
import { compareIdSets, createRandomIdSet, ID } from './testHelper.js'
/**
* @param {Array<[number, number, number]>} ops
*/
const simpleConstructIdSet = ops => {
const idset = d.createIdSet()
ops.forEach(op => {
idset.add(op[0], op[1], op[2])
})
return idset
}
/**
* @param {t.TestCase} _tc
*/
export const testIdsetMerge = _tc => {
t.group('filter out empty items (1))', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 0]]),
simpleConstructIdSet([])
)
})
t.group('filter out empty items (2))', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 0], [0, 2, 0]]),
simpleConstructIdSet([])
)
})
t.group('filter out empty items (3 - end))', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 1], [0, 2, 0]]),
simpleConstructIdSet([[0, 1, 1]])
)
})
t.group('filter out empty items (4 - middle))', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 1], [0, 2, 0], [0, 3, 1]]),
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
)
})
t.group('filter out empty items (5 - beginning))', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 0], [0, 2, 1], [0, 3, 1]]),
simpleConstructIdSet([[0, 2, 1], [0, 3, 1]])
)
})
t.group('merge of overlapping id ranges', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 2], [0, 0, 2]]),
simpleConstructIdSet([[0, 0, 3]])
)
})
t.group('construct without hole', () => {
compareIdSets(
simpleConstructIdSet([[0, 1, 2], [0, 3, 1]]),
simpleConstructIdSet([[0, 1, 3]])
)
})
}
/**
* @param {t.TestCase} _tc
*/
export const testDiffing = _tc => {
t.group('simple case (1))', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]]),
simpleConstructIdSet([[0, 3, 1]])
),
simpleConstructIdSet([[0, 1, 1]])
)
})
t.group('subset left', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 1, 1]])
),
simpleConstructIdSet([[0, 2, 2]])
)
})
t.group('subset right', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 3, 1]])
),
simpleConstructIdSet([[0, 1, 2]])
)
})
t.group('subset middle', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 2, 1]])
),
simpleConstructIdSet([[0, 1, 1], [0, 3, 1]])
)
})
t.group('overlapping left', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 0, 2]])
),
simpleConstructIdSet([[0, 2, 2]])
)
})
t.group('overlapping right', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 3, 5]])
),
simpleConstructIdSet([[0, 1, 2]])
)
})
t.group('overlapping completely', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3]]),
simpleConstructIdSet([[0, 0, 5]])
),
simpleConstructIdSet([])
)
})
t.group('overlapping into new range', () => {
compareIdSets(
d.diffIdSet(
simpleConstructIdSet([[0, 1, 3], [0, 5, 2]]),
simpleConstructIdSet([[0, 0, 6]])
),
simpleConstructIdSet([[0, 6, 1]])
)
})
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDiffing = tc => {
const clients = 4
const clockRange = 100
const ds1 = createRandomIdSet(tc.prng, clients, clockRange)
const ds2 = createRandomIdSet(tc.prng, clients, clockRange)
const merged = d.mergeIdSets([ds1, ds2])
const e1 = d.diffIdSet(ds1, ds2)
const e2 = d.diffIdSet(merged, ds2)
compareIdSets(e1, e2)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDeletes = tc => {
const clients = 1
const clockRange = 100
const idset = createRandomIdSet(tc.prng, clients, clockRange)
const client = Array.from(idset.clients.keys())[0]
const clock = prng.int31(tc.prng, 0, clockRange)
const len = prng.int31(tc.prng, 0, math.round((clockRange - clock) * 1.2)) // allow exceeding range to cover more edge cases
const idsetOfDeletes = d.createIdSet()
idsetOfDeletes.add(client, clock, len)
const diffed = d.diffIdSet(idset, idsetOfDeletes)
idset.delete(client, clock, len)
for (let i = 0; i < len; i++) {
t.assert(!idset.has(client, clock + i))
}
compareIdSets(idset, diffed)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatMergingMultipleIdsets = tc => {
const clients = 4
const clockRange = 100
/**
* @type {Array<d.IdSet>}
*/
const idss = []
for (let i = 0; i < 3; i++) {
idss.push(createRandomIdSet(tc.prng, clients, clockRange))
}
const merged = d.mergeIdSets(idss)
const mergedReverse = d.mergeIdSets(idss.reverse())
compareIdSets(merged, mergedReverse)
const composed = d.createIdSet()
for (let iclient = 0; iclient < clients; iclient++) {
for (let iclock = 0; iclock < clockRange + 42; iclock++) {
const mergedHas = merged.hasId(new ID(iclient, iclock))
const oneHas = idss.some(ids => ids.hasId(new ID(iclient, iclock)))
t.assert(mergedHas === oneHas)
if (oneHas) {
composed.add(iclient, iclock, 1)
}
}
}
compareIdSets(merged, composed)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomDiffing2 = tc => {
const clients = 4
const clockRange = 100
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
const idsExclude = createRandomIdSet(tc.prng, clients, clockRange)
const merged = d.mergeIdSets([ids1, ids2])
const mergedExcluded = d.diffIdSet(merged, idsExclude)
const e1 = d.diffIdSet(ids1, idsExclude)
const e2 = d.diffIdSet(ids2, idsExclude)
const excludedMerged = d.mergeIdSets([e1, e2])
compareIdSets(mergedExcluded, excludedMerged)
}
/**
* @param {t.TestCase} tc
*/
export const testRepeatRandomIntersects = tc => {
const clients = 4
const clockRange = 100
const ids1 = createRandomIdSet(tc.prng, clients, clockRange)
const ids2 = createRandomIdSet(tc.prng, clients, clockRange)
const intersected = d.intersectSets(ids1, ids2)
for (let client = 0; client < clients; client++) {
for (let clock = 0; clock < clockRange; clock++) {
t.assert((ids1.has(client, clock) && ids2.has(client, clock)) === intersected.has(client, clock))
}
}
const diffed1 = d.diffIdSet(ids1, ids2)
const altDiffed1 = d.diffIdSet(ids1, intersected)
compareIdSets(diffed1, altDiffed1)
}
/**
* The allocation-free `covers` / `coveredLength` must agree with per-id `has` and with the
* allocating `slice(...)` reduction.
*
* @param {t.TestCase} tc
*/
export const testRepeatRandomCoversAndCoveredLength = tc => {
const clients = 4
const clockRange = 100
const ids = createRandomIdSet(tc.prng, clients, clockRange)
for (let iter = 0; iter < 300; iter++) {
const client = prng.int31(tc.prng, 0, clients - 1)
const clock = prng.int31(tc.prng, 0, clockRange)
const len = prng.int31(tc.prng, 1, clockRange - clock + 5) // may exceed the range to cover edges
// reference by scanning every id in the range
let refCovered = 0
let refCovers = true
for (let c = clock; c < clock + len; c++) {
if (ids.has(client, c)) refCovered++
else refCovers = false
}
t.assert(ids.covers(client, clock, len) === refCovers)
t.assert(ids.coveredLength(client, clock, len) === refCovered)
// cross-check against the allocating slice
const sliceCovered = ids.slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)
t.assert(ids.coveredLength(client, clock, len) === sliceCovered)
}
}
/**
* The struct that covers `clock` for the document's local client.
*
* @param {Y.Doc} doc
* @param {number} clock
* @return {any}
*/
const structAt = (doc, clock) => {
const structs = /** @type {Array<any>} */ (doc.store.clients.get(doc.clientID))
return structs[Y.findIndexSS(structs, clock)]
}
/**
* `gcIdSet` collects the deleted content referenced by an IdSet on a `gc: false` document,
* without changing the observable state, and the result still converges with a peer.
*
* @param {t.TestCase} _tc
*/
export const testGcIdSetBasic = _tc => {
const doc = new Y.Doc({ gc: false })
const yarr = doc.get('array')
yarr.insert(0, ['a', 'b', 'c', 'd'])
yarr.delete(1, 2) // delete 'b' (clock 1) and 'c' (clock 2)
// gc is disabled, so the deleted content is retained
t.assert(structAt(doc, 1).content instanceof Y.ContentAny)
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
// observable state is unchanged
t.compare(yarr.toArray(), ['a', 'd'])
// every collectible deleted item is now collected
doc.store.clients.forEach(structs => {
structs.forEach(struct => {
if (struct.constructor === Y.Item && struct.deleted && !struct.keep) {
t.assert(struct.content instanceof Y.ContentDeleted)
}
})
})
// still converges with a fresh peer
const peer = new Y.Doc()
Y.applyUpdate(peer, Y.encodeStateAsUpdate(doc))
t.compare(peer.get('array').toArray(), ['a', 'd'])
}
/**
* `gcIdSet` splits structs at the IdSet boundaries, so only the exact referenced content is
* collected - the rest of a partially-covered deleted run is preserved.
*
* @param {t.TestCase} _tc
*/
export const testGcIdSetPartialRange = _tc => {
const doc = new Y.Doc({ gc: false })
const yarr = doc.get('array')
yarr.insert(0, ['a', 'b', 'c', 'd', 'e'])
yarr.delete(1, 4) // delete 'b','c','d','e' (clocks 1..4), retained because gc is off
// collect only the middle of the deleted run: clocks 2..3 ('c', 'd')
const ids = d.createIdSet()
ids.add(doc.clientID, 2, 2)
Y.gcIdSet(doc, ids)
t.compare(yarr.toArray(), ['a'])
// 'b' (clock 1) and 'e' (clock 4) are outside the IdSet -> still retained
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
t.assert(structAt(doc, 4).deleted && structAt(doc, 4).content instanceof Y.ContentAny)
// 'c','d' (clocks 2..3) were inside the IdSet -> collected
t.assert(structAt(doc, 2).content instanceof Y.ContentDeleted)
}
/**
* Ids that don't reference collectible content (live content) are skipped.
*
* @param {t.TestCase} _tc
*/
export const testGcIdSetSkipsLiveContent = _tc => {
const doc = new Y.Doc({ gc: false })
const yarr = doc.get('array')
yarr.insert(0, ['a', 'b', 'c'])
yarr.delete(1, 1) // delete only 'b'
// pass *all* ids (live + deleted); only the deleted one is collectible
Y.gcIdSet(doc, Y.createInsertSetFromStructStore(doc.store, false))
t.compare(yarr.toArray(), ['a', 'c'])
t.assert(structAt(doc, 0).content instanceof Y.ContentAny) // 'a' is live -> untouched
t.assert(structAt(doc, 2).content instanceof Y.ContentAny) // 'c' is live -> untouched
t.assert(structAt(doc, 1).content instanceof Y.ContentDeleted) // 'b' was deleted -> collected
}
/**
* Calling `gcIdSet` twice is a no-op the second time.
*
* @param {t.TestCase} _tc
*/
export const testGcIdSetIdempotent = _tc => {
const doc = new Y.Doc({ gc: false })
const yarr = doc.get('array')
yarr.insert(0, ['a', 'b', 'c'])
yarr.delete(0, 2)
const ds = Y.createDeleteSetFromStructStore(doc.store)
Y.gcIdSet(doc, ds)
const encoded = Y.encodeStateAsUpdate(doc)
Y.gcIdSet(doc, ds) // second pass changes nothing
t.compare(yarr.toArray(), ['c'])
t.compare(Y.encodeStateAsUpdate(doc), encoded)
}
/**
* Items flagged with `keep` (e.g. protected by an UndoManager) are not collected.
*
* @param {t.TestCase} _tc
*/
export const testGcIdSetRespectsKeep = _tc => {
const doc = new Y.Doc({ gc: false })
const yarr = doc.get('array')
yarr.insert(0, ['a', 'b', 'c'])
yarr.delete(1, 1) // delete 'b' (clock 1)
structAt(doc, 1).keep = true // protect it from gc
Y.gcIdSet(doc, Y.createDeleteSetFromStructStore(doc.store))
// 'b' was kept -> still retained, not collected
t.assert(structAt(doc, 1).deleted && structAt(doc, 1).content instanceof Y.ContentAny)
}

2348
tests/attribution.tests.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

197
tests/delta.tests.js Normal file
View File

@@ -0,0 +1,197 @@
import * as Y from '../src/index.js'
import * as delta from 'lib0/delta'
import * as t from 'lib0/testing'
import * as s from 'lib0/schema'
/**
* Delta is a versatile format enabling you to efficiently describe changes. It is part of lib0, so
* that non-yjs applications can use it without consuming the full Yjs package. It is well suited
* for efficiently describing state & changesets.
*
* Assume we start with the text "hello world". Now we want to delete " world" and add an
* exclamation mark. The final content should be "hello!" ("hello world" => "hello!")
*
* In most editors, you would describe the necessary changes as replace operations using indexes.
* However, this might become ambiguous when many changes are involved.
*
* - delete range 5-11
* - insert "!" at position 11
*
* Using the delta format, you can describe the changes similar to what you would do in an text editor.
* The "|" describes the current cursor position.
*
* - d.retain(5) - "|hello world" => "hello| world" - jump over the next five characters
* - d.delete(6) - "hello| world" => "hello|" - delete the next 6 characres
* - d.insert('!') - "hello!|" - insert "!" at the current position
* => compact form: d.retain(5).delete(6).insert('!')
*
* You can also apply the changes in two distinct steps and then rebase the op so that you can apply
* them in two distinct steps.
* - delete " world": d1 = delta.create().retain(5).delete(6)
* - insert "!": d2 = delta.create().retain(11).insert('!')
* - rebase d2 on-top of d1: d2.rebase(d1) == delta.create().retain(5).insert('!')
* - merge into a single change: d1.apply(d2) == delta.create().retain(5).delete(6).insert(!)
*
* @param {t.TestCase} _tc
*/
export const testDeltaBasics = _tc => {
// the state of our text document
const state = delta.create().insert('hello world')
// describe changes: delete " world" & insert "!"
const change = delta.create().retain(5).delete(6).insert('!')
// apply changes to state
state.apply(change)
// compare state to expected state
t.assert(state.equals(delta.create().insert('hello!').done()))
}
/**
* lib0 also ships a schema library that can be used to validate JSON objects and custom data types,
* like Yjs types.
*
* As a convention, schemas are usually prefixed with a $ sign. This clarifies the difference
* between a schema, and an instance of a schema.
*
* const $myobj = s.$object({ key: s.$number })
* let inputValue: any
* if ($myobj.check(inputValue)) {
* inputValue // is validated and of type $myobj
* }
*
* We can also define the expected values on a delta.
*
* @param {t.TestCase} _tc
*/
export const testDeltaBasicSchema = _tc => {
const $d = delta.$delta({ attrs: { key: s.$string }, children: s.$number, text: false })
const d = delta.create($d)
// @ts-expect-error
d.setAttr('key', false) // invalid change: will throw a type error
t.fails(() => {
// @ts-expect-error
d.apply(delta.create().setAttr('key', false)) // invalid delta: will throw a type error
})
}
/**
* Deltas can describe changes on attributes and children. Textual insertions are children. But we
* may also insert json-objects and other deltas as children.
* Key-value pairs can be represented as attributes. This "convoluted" changeset enables us to
* describe many changes in the same breath:
*
* delta.create().setAttr('a', 42).retain(5).delete(6).insert('!').deleteAttr('b')
*
* @param {t.TestCase} _tc
*/
export const testDeltaValues = _tc => {
const change = delta.create().setAttr('a', 42).deleteAttr('b').retain(5).delete(6).insert('!').insert([{ my: 'custom object' }])
// iterate through attribute changes
for (const attrChange of change.attrs) {
if (delta.$insertOp.check(attrChange)) {
console.log(`set ${attrChange.key} to ${attrChange.value}`)
} else if (delta.$deleteOp.check(attrChange)) {
console.log(`delete ${attrChange.key}`)
}
}
// iterate through child changes
for (const childChange of change.children) {
if (delta.$retainOp.check(childChange)) {
console.log(`retain ${childChange.retain} child items`)
} else if (delta.$deleteOp.check(childChange)) {
console.log(`delete ${childChange.delete} child items`)
} else if (delta.$insertOp.check(childChange)) {
console.log('insert child items:', childChange.insert)
} else if (delta.$textOp.check(childChange)) {
console.log('insert textual content', childChange.insert)
}
}
}
/**
* The new delta defines changes on attributes (key-value) and child elements (list & text), but can
* also be used to describe the current state of a document.
*
* 1. apply a delta to change a yjs type
* 2. observe deltas to read the differences
* 3. merge deltas to reflect multiple changes in a single delta
* 4. All Yjs types fully support the delta format. It is no longer necessary to define the type (such as Y.Array)
*
* @param {t.TestCase} _tc
*/
export const testBasics = _tc => {
const ydoc = new Y.Doc()
const ynode = ydoc.get('my data')
/**
* @type {delta.Delta<{attrs: { a: number }, children: { my: string }, text: true }>}
*/
let observedDelta = delta.create()
ynode.observe(event => {
observedDelta = event.deltaDeep
console.log('ynode changed:', observedDelta.toJSON())
})
// define a change: set attribute: a=42
const attrChange = delta.create().setAttr('a', 42).done()
// define a change: insert textual content and an object
const childChange = delta.create().insert('hello').insert([{ my: 'object' }]).done()
// merge changes
const mergedChanges = delta.create(delta.$deltaAny)
mergedChanges.apply(attrChange)
mergedChanges.apply(childChange).done()
console.log('merged changes: ', mergedChanges.toJSON())
ynode.applyDelta(mergedChanges)
// the observed change should equal the applied change
t.assert(observedDelta.equals(mergedChanges))
// read the current state of the yjs types as a delta
const currState = ynode.toDeltaDeep()
t.assert(currState.equals(mergedChanges)) // equal to the changes that we applied
}
/**
* Deltas allow us to describe the differences between two Yjs documents though "Attributions".
*
* - We can attribute changes to a user, or a group of users
* - There are 'insert', 'delete', and 'format' attributions
* - When we render attributions, we render inserted & deleted content as an insertions with special
* attributes which allow you to..
* -- Render deleted content using a strikethrough: I.e. `hello w̶o̶r̶l̶d̶!`
* -- Render attributed insertions using a background color.
*
* @param {t.TestCase} _tc
*/
export const testAttributions = _tc => {
const ydocV1 = new Y.Doc()
const ynodeV1 = ydocV1.get('txt')
ynodeV1.applyDelta(delta.create().insert('hello world').done())
// create a new version with updated content
const ydoc = new Y.Doc()
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
const ynode = ydoc.get('txt')
// delete " world" and insert exclamation mark "!".
ynode.applyDelta(delta.create().retain(5).delete(6).insert('!').done())
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
// get the attributed differences
const attributedContent = ynode.toDelta({ renderer })
console.log('attributed content', attributedContent.toJSON())
t.assert(attributedContent.equals(delta.create().insert('hello').insert(' world', null, { delete: [] }).insert('!', null, { insert: [] }).done()))
// for editor bindings, it is also necessary to observe changes and get the attributed changes
ynode.observe(event => {
const attributedChange = event.getDelta({ renderer })
console.log('the attributed change', attributedChange.toJSON())
t.assert(attributedChange.done().equals(delta.create().retain(11).insert('!', null, { insert: [] }).done()))
const unattributedChange = event.delta
console.log('the UNattributed change', unattributedChange.toJSON())
t.assert(unattributedChange.equals(delta.create().retain(5).insert('!').done()))
})
/**
* Content now has different representations.
* - The UNattributed representation renders the latest state, without history.
* - The attributed representation renders the differences.
*
* Attributed: 'hello<delete> world</delete><insert>!</insert>'
* UNattributed: 'world!'
*/
// Apply a change to the attributed content
ynode.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer })
// // Equivalent to applying a change to the UNattributed content:
// ynode.applyDelta(delta.create().retain(5).insert('!'))
}

354
tests/doc.tests.js Normal file
View File

@@ -0,0 +1,354 @@
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.get('')
ydoc.on('afterTransaction', tr => {
if (tr.origin === 'test') {
yxml.toJSON()
}
})
ydoc.transact(_tr => {
for (let i = 0; i < 15000; i++) {
yxml.push([new Y.Node('a')])
}
}, 'test')
}
/**
* @param {t.TestCase} _tc
*/
export const testFindTypeInOtherDoc = _tc => {
const ydoc = new Y.Doc()
const ymap = ydoc.get()
const ytext = ymap.setAttr('ytext', new Y.Node())
const ydocClone = new Y.Doc()
Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc))
/**
* @param {Y.Node} ynode
* @param {Y.Doc} otherYdoc
* @return {Y.Node}
*/
const findTypeInOtherYdoc = (ynode, otherYdoc) => {
const ydoc = /** @type {Y.Doc} */ (ynode.doc)
if (ynode._item === null) {
/**
* If is a root type, we need to find the root key in the original ydoc
* and use it to get the type in the other ydoc.
*/
const rootKey = Array.from(ydoc.share.keys()).find(
(key) => ydoc.share.get(key) === ynode
)
if (rootKey == null) {
throw new Error('type does not exist in other ydoc')
}
return otherYdoc.get(rootKey)
} else {
/**
* If it is a sub type, we use the item id to find the history type.
*/
const ynodeItem = ynode._item
const otherStructs = otherYdoc.store.clients.get(ynodeItem.id.client) ?? []
const itemIndex = Y.findIndexSS(
otherStructs,
ynodeItem.id.clock
)
const otherItem = /** @type {Y.Item} */ (otherStructs[itemIndex])
const otherContent = /** @type {Y.ContentType} */ (otherItem.content)
return /** @type {Y.Node} */ (otherContent.type)
}
}
t.assert(findTypeInOtherYdoc(ymap, ydocClone) != null)
t.assert(findTypeInOtherYdoc(ytext, ydocClone) != null)
}
/**
* 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.get('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.get('').insert(0, 'h')
doc1.get().insert(1, 'i')
const doc2 = new Y.Doc()
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
t.assert(doc2.get().toString() === 'hi')
t.assert(doc2.get('').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.get('array')
arr.push(['test1'])
const map = doc.get('map')
map.setAttr('k1', 'v1')
const map2 = new Y.Node()
map.setAttr('k2', map2)
map2.setAttr('m2k1', 'm2v1')
t.compare(doc.toJSON(), {
array: { children: ['test1'] },
map: {
attrs: {
k1: 'v1',
k2: {
attrs: {
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.get('mysubdocs')
const docA = new Y.Doc({ guid: 'a' })
docA.load()
subdocs.setAttr('a', docA)
t.compare(event, [['a'], [], ['a']])
event = null
subdocs.getAttr('a').load()
t.assert(event === null)
event = null
subdocs.getAttr('a').destroy()
t.compare(event, [['a'], ['a'], []])
subdocs.getAttr('a').load()
t.compare(event, [[], [], ['a']])
subdocs.setAttr('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
t.compare(event, [['a'], [], []])
subdocs.getAttr('b').load()
t.compare(event, [[], [], ['a']])
const docC = new Y.Doc({ guid: 'c' })
docC.load()
subdocs.setAttr('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.get('mysubdocs').getAttr('a').load()
t.compare(event, [[], [], ['a']])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])
doc2.get('mysubdocs').deleteAttr('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.get()
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.get().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.get()
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.get().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.get()
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)
}

75
tests/encoding.tests.js Normal file
View File

@@ -0,0 +1,75 @@
import * as t from 'lib0/testing'
import {
contentRefs,
readContentDeleted,
readContentBinary,
readContentString,
readContentJSON,
readContentEmbed,
readContentType,
readContentFormat,
readContentAny,
readContentDoc
} from '../src/ynode.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
}
/**
* 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.get().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.get().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<ArrayBuffer>>}
*/
const updates = []
ydoc.on('update', update => {
updates.push(update)
})
ydoc.get().insert(0, 'a')
ydoc.get().insert(0, 'b')
ydoc.get().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)
}

39
tests/index.js Normal file
View File

@@ -0,0 +1,39 @@
/* 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 * as idset from './IdSet.tests.js'
import * as idmap from './IdMap.tests.js'
import * as attribution from './attribution.tests.js'
import * as delta from './delta.tests.js'
import * as schema from './schema.tests.js'
import { runTests } from 'lib0/testing'
import { isBrowser, isNode } from 'lib0/environment'
import * as log from 'lib0/logging'
if (isBrowser) {
log.createVConsole(document.body)
}
const tests = {
doc, map, array, text, xml, encoding, undoredo, compatibility, snapshot, updates, relativePositions, idset, idmap, attribution, delta, schema
}
const run = async () => {
const success = await runTests(tests)
/* istanbul ignore next */
if (isNode) {
process.exit(success ? 0 : 1)
}
}
run()

View File

@@ -0,0 +1,372 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import * as dpos from 'lib0/delta/position'
/**
* @param {Y.Node<{text:true}>} 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.get()
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.get()
ytext.insert(0, 'abc')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} _tc
*/
export const testRelativePositionCase3 = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
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.get()
ytext.insert(0, '1')
checkRelativePositions(ytext)
}
/**
* @param {t.TestCase} _tc
*/
export const testRelativePositionCase5 = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
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.get()
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.get('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.get()
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.get()
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)
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsFlatTextRoundtrip = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
ytext.insert(0, 'hello world')
const positions = [dpos.create([0]), dpos.create([5]), dpos.create([5], -1), dpos.create([11])]
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
t.assert(rposs.every(rpos => rpos !== null))
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
positions.forEach((pos, i) => {
const b = back[i]
t.assert(b !== null && dpos.equals(b, pos))
})
// the singular wrappers behave like a one-element batch
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
t.assert(Y.compareRelativePositions(rpos, rposs[1]))
const single = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
t.assert(single !== null && dpos.equals(single, positions[1]))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsNested = _tc => {
const ydoc = new Y.Doc()
const yroot = ydoc.get('arr')
yroot.insert(0, ['a', new Y.Node(), 'b'])
const child = /** @type {Y.Node<any>} */ (yroot.get(1))
child.insert(0, 'xyz')
const positions = [dpos.create([1, 2]), dpos.create([0])]
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions)
t.assert(rposs.every(rpos => rpos !== null))
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs)
positions.forEach((pos, i) => {
const b = back[i]
t.assert(b !== null && dpos.equals(b, pos))
})
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsAttrDescent = _tc => {
const ydoc = new Y.Doc()
const ymap = ydoc.get('map')
const child = ymap.setAttr('k', new Y.Node())
child.insert(0, 'ab')
const pos = dpos.create(['k', 1])
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
t.assert(rpos !== null)
const back = Y.createDeltaPositionFromRelativePosition(ymap, rpos)
t.assert(back !== null && dpos.equals(back, pos))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsAttrLeaf = _tc => {
const ydoc = new Y.Doc()
const ymap = ydoc.get('map')
ymap.setAttr('k', 42)
const pos = dpos.create(['k'])
const rpos = Y.createRelativePositionFromDeltaPosition(ymap, pos)
t.assert(rpos !== null && rpos.item !== null)
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
// the attribute-leaf anchor survives overwriting the attribute
ymap.setAttr('k', 43)
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ymap, rpos)), pos))
// .. but not deleting it
ymap.deleteAttr('k')
t.assert(Y.createDeltaPositionFromRelativePosition(ymap, rpos) === null)
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsDocRooted = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get('text')
ytext.insert(0, 'hello')
// doc-rooted paths start with a root-type name; numeric or terminal-string steps at doc level are unresolvable
const positions = [dpos.create(['text', 3]), dpos.create([0]), dpos.create(['text'])]
const rposs = Y.createRelativePositionsFromDeltaPositions(ydoc, positions)
t.assert(rposs[0] !== null && rposs[1] === null && rposs[2] === null)
const back = Y.createDeltaPositionFromRelativePosition(ydoc, rposs[0])
t.assert(back !== null && dpos.equals(back, positions[0]))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsAssocThroughEdit = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
ytext.insert(0, '2')
ytext.insert(0, '1')
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, [dpos.create([1]), dpos.create([1], -1)])
ytext.insert(1, 'x')
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
t.assert(back[0] !== null && dpos.equals(back[0], dpos.create([2])))
t.assert(back[1] !== null && dpos.equals(back[1], dpos.create([1], -1)))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsSurviveConcurrentEdit = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get('text')
ytext.insert(0, 'hello')
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([2]))
t.assert(rpos !== null)
const encoded = Y.encodeRelativePosition(/** @type {Y.RelativePosition} */ (rpos))
const ydoc2 = new Y.Doc()
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc))
const ytext2 = ydoc2.get('text')
ytext2.insert(0, 'abc')
const back = Y.createDeltaPositionFromRelativePosition(ytext2, Y.decodeRelativePosition(encoded))
t.assert(back !== null && dpos.equals(back, dpos.create([5])))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsWithRenderer = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
ytext.insert(0, 'hello world')
const v1 = Y.cloneDoc(ydoc)
ytext.delete(1, 6)
ytext.insert(1, 'x')
const renderer = Y.createDiffRenderer(v1, ydoc)
const pos = dpos.create([9]) // pos after "hello wo" in the rendered diff
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, pos, { renderer })
t.assert(rpos !== null)
const backWith = Y.createDeltaPositionFromRelativePosition(ytext, rpos, { renderer })
t.assert(backWith !== null && dpos.equals(backWith, pos))
const backWithout = Y.createDeltaPositionFromRelativePosition(ytext, rpos)
t.assert(backWithout !== null && dpos.equals(backWithout, dpos.create([3])))
// the node's own renderer is the default - matching toDelta / node.delta
ytext.useRenderer(renderer)
const rpos2 = Y.createRelativePositionFromDeltaPosition(ytext, pos)
t.assert(Y.compareRelativePositions(rpos, rpos2))
const backDefault = Y.createDeltaPositionFromRelativePosition(ytext, rpos2)
t.assert(backDefault !== null && dpos.equals(backDefault, pos))
// explicit renderer: null overrides the node's renderer
const backNull = Y.createDeltaPositionFromRelativePosition(ytext, rpos2, { renderer: null })
t.assert(backNull !== null && dpos.equals(backNull, dpos.create([3])))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsUnresolvable = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
ytext.insert(0, 'abc')
const positions = [
dpos.create([0, 0]), // descend into a scalar
dpos.create(['missing', 0]), // missing attribute
dpos.create([]), // a node reference doesn't round-trip
dpos.create([10, 0]), // out-of-range non-terminal index
dpos.create([1]) // resolvable - other entries must not affect it
]
const rposs = Y.createRelativePositionsFromDeltaPositions(ytext, positions)
t.assert(rposs[0] === null && rposs[1] === null && rposs[2] === null && rposs[3] === null)
t.assert(rposs[4] !== null)
const back = Y.createDeltaPositionsFromRelativePositions(ytext, rposs)
t.assert(back[0] === null && back[1] === null && back[2] === null && back[3] === null)
t.assert(back[4] !== null && dpos.equals(back[4], positions[4]))
}
/**
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsEndOfNode = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get()
ytext.insert(0, 'hello')
const rpos = Y.createRelativePositionFromDeltaPosition(ytext, dpos.create([5]))
t.assert(rpos !== null && rpos.item === null)
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([5])))
// an end-of-node position sticks to the end
ytext.insert(5, ' world')
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(ytext, rpos)), dpos.create([11])))
// empty node
const yempty = ydoc.get('empty')
const rposE = Y.createRelativePositionFromDeltaPosition(yempty, dpos.create([0]))
t.assert(rposE !== null && rposE.item === null)
t.assert(dpos.equals(/** @type {dpos.Pos} */ (Y.createDeltaPositionFromRelativePosition(yempty, rposE)), dpos.create([0])))
}
/**
* Positions inside a nested node that is deleted but still rendered (e.g. a suggestion-mode
* subtree delete). Requires gc: false - with gc enabled, the deleted subtree's content is
* destroyed and such positions are unresolvable.
*
* @param {t.TestCase} _tc
*/
export const testDeltaPositionsInDeletedRenderedSubtree = _tc => {
const ydoc = new Y.Doc({ gc: false })
const yroot = ydoc.get('r')
yroot.insert(0, [new Y.Node(), 'x'])
const mid = /** @type {Y.Node<any>} */ (yroot.get(0))
mid.insert(0, ['q', new Y.Node()])
const leaf = /** @type {Y.Node<any>} */ (mid.get(1))
leaf.insert(0, 'hello')
const v1 = Y.cloneDoc(ydoc)
yroot.delete(0, 1) // delete the whole `mid` subtree - the diff renderer still renders it
const renderer = Y.createDiffRenderer(v1, ydoc)
// the underlying primitive resolves indexes inside the deleted-but-rendered type
const rposLeaf = Y.createRelativePositionFromTypeIndex(leaf, 3, 0, renderer)
t.assert(Y.createAbsolutePositionFromRelativePosition(rposLeaf, ydoc, true, renderer)?.index === 3)
// .. and delta positions round-trip through the deleted subtree
const positions = [dpos.create([0, 1, 3]), dpos.create([0, 0]), dpos.create([1])]
const rposs = Y.createRelativePositionsFromDeltaPositions(yroot, positions, { renderer })
t.assert(rposs.every(rpos => rpos !== null))
const back = Y.createDeltaPositionsFromRelativePositions(yroot, rposs, { renderer })
positions.forEach((pos, i) => {
const b = back[i]
t.assert(b !== null && dpos.equals(b, pos))
})
}

327
tests/schema.tests.js Normal file
View File

@@ -0,0 +1,327 @@
import * as t from 'lib0/testing'
import * as s from 'lib0/schema'
import * as Y from '../src/index.js'
/**
* @param {any} val
* @return {ContentAttribute<any>}
*/
const attr = val => Y.createContentAttribute('u', val)
/**
* An IdMap holding one attribute value per range, all on client 1.
*
* @param {Array<any>} vals
* @return {IdMap<any>}
*/
const idMapOf = vals => {
const m = Y.createIdMap()
vals.forEach((v, i) => m.add(1, i * 10, 10, [attr(v)]))
return m
}
/**
* @return {IdSet}
*/
const idSetOf = () => {
const set = Y.createIdSet()
set.add(1, 0, 10)
return set
}
/**
* Every schema on the public surface, paired with a value it must accept.
*
* The explicit return annotation is required: without it the mixed tuples infer a union element
* type and destructuring `$schema` loses `.check`.
*
* @return {Array<[string, s.Schema<any>, any]>}
*/
const $schemas = () => [
['$idSet', Y.$idSet, Y.createIdSet()],
['$idMapAny', Y.$idMapAny, Y.createIdMap()],
['$idMap($string)', Y.$idMap(s.$string), idMapOf(['a'])],
['$contentIds', Y.$contentIds, Y.createContentIds()],
['$contentMap', Y.$contentMap, Y.createContentMap()],
['$doc', Y.$doc, new Y.Doc()],
['$nodeAny', Y.$nodeAny, new Y.Doc().get('n')],
['$node({name:"p"})', Y.$node({ name: 'p' }), new Y.Doc().get('n', 'p')]
]
/**
* @param {t.TestCase} _tc
*/
export const testSchemasAcceptTheirOwnInstance = _tc => {
$schemas().forEach(([name, $schema, instance]) => {
t.group(name, () => {
t.assert($schema.check(instance))
})
})
}
/**
* No schema accepts a foreign value, and none throws. `s.$object` returns false for null and reads
* its shape's keys off the value, so primitives / arrays / Maps fail on the `undefined` fields.
*
* @param {t.TestCase} _tc
*/
export const testSchemasRejectJunk = _tc => {
const junk = [null, undefined, {}, 0, 1, '', 'idSet', [], true, false, new Map(), new Set(), () => {}]
$schemas().forEach(([name, $schema]) => {
t.group(name, () => {
junk.forEach(j => {
t.assert(!$schema.check(j), `must reject ${typeof j} ${String(j)}`)
})
})
})
}
/**
* The nominal tags discriminate IdSet from IdMap in both directions. Structurally an IdSet
* (`{clients}`) is a *subset* of an IdMap (`{clients, attrsH, attrs}`), and `s.$object` always
* permits excess properties - so only a nominal check can tell them apart.
*
* @param {t.TestCase} _tc
*/
export const testIdSetIdMapDiscrimination = _tc => {
t.assert(!Y.$idSet.check(Y.createIdMap()), '$idSet rejects an IdMap')
t.assert(!Y.$idMapAny.check(Y.createIdSet()), '$idMapAny rejects an IdSet')
t.assert(!Y.$idMap(s.$string).check(Y.createIdSet()), '$idMap rejects an IdSet')
}
/**
* @param {t.TestCase} _tc
*/
export const testContentIdsVsContentMap = _tc => {
const cIds = Y.createContentIds()
const cMap = Y.createContentMap()
t.group('positive', () => {
t.assert(Y.$contentIds.check(cIds))
t.assert(Y.$contentMap.check(cMap))
})
t.group('both directions', () => {
t.assert(!Y.$contentIds.check(cMap), '$contentIds rejects a ContentMap')
t.assert(!Y.$contentMap.check(cIds), '$contentMap rejects a ContentIds')
})
t.group('mixed shapes', () => {
const a = { inserts: Y.createIdSet(), deletes: Y.createIdMap() }
const b = { inserts: Y.createIdMap(), deletes: Y.createIdSet() }
t.assert(!Y.$contentIds.check(a))
t.assert(!Y.$contentIds.check(b))
t.assert(!Y.$contentMap.check(a))
t.assert(!Y.$contentMap.check(b))
})
t.group('missing fields', () => {
t.assert(!Y.$contentIds.check({ inserts: Y.createIdSet() }), 'deletes missing')
t.assert(!Y.$contentIds.check({ deletes: Y.createIdSet() }), 'inserts missing')
})
t.group('excess properties are allowed - s.$object reads the shape keys', () => {
t.assert(Y.$contentIds.check({ ...Y.createContentIds(), extra: 1 }))
})
}
/**
* $idMap($attrs) checks the mapped values, not merely the nominal type.
*
* @param {t.TestCase} _tc
*/
export const testIdMapValueSchema = _tc => {
t.group('accepts the matching value type, rejects the other', () => {
t.assert(Y.$idMap(s.$string).check(idMapOf(['a', 'b'])))
t.assert(!Y.$idMap(s.$string).check(idMapOf([1, 2])))
t.assert(Y.$idMap(s.$number).check(idMapOf([1, 2])))
t.assert(!Y.$idMap(s.$number).check(idMapOf(['a', 'b'])))
})
t.group('a single mismatching value is enough to reject', () => {
t.assert(!Y.$idMap(s.$string).check(idMapOf(['a', 1])))
t.assert(!Y.$idMap(s.$number).check(idMapOf([1, 'a'])))
})
t.group('an empty map holds no values, so it satisfies every $attrs', () => {
t.assert(Y.$idMap(s.$string).check(Y.createIdMap()))
t.assert(Y.$idMap(s.$number).check(Y.createIdMap()))
})
t.group('$idMapAny ignores the value type', () => {
t.assert(Y.$idMapAny.check(idMapOf(['a'])))
t.assert(Y.$idMapAny.check(idMapOf([1])))
})
}
/**
* `check()` must not mutate its argument. `AttrRanges#getIds` sorts and splices in place, so a check
* built on it would silently reorder the caller's map; `everyAttr` iterates the raw ranges instead.
*
* @param {t.TestCase} _tc
*/
export const testIdMapCheckDoesNotMutate = _tc => {
const m = Y.createIdMap()
// deliberately out of clock order, so a sort would be observable
m.add(1, 10, 5, [attr('b')])
m.add(1, 0, 10, [attr('a')])
const ranges = /** @type {any} */ (m.clients.get(1))
t.assert(ranges.sorted === false, 'precondition: not yet sorted')
t.assert(Y.$idMap(s.$string).check(m))
t.assert(ranges.sorted === false, 'check() must not have sorted the ranges')
}
/**
* Every real IdMap producer yields a map that $idMap validates correctly.
*
* Every producer fed only string values must be accepted by `$idMap($string)`. The `keepsValues`
* flag drives the far more important negative assertion: `$idMap($number)` must *reject* it. Only
* the three genuinely-emptying producers are exempt - they hold nothing, so they satisfy every
* `$attrs` vacuously and cannot discriminate. Without the negative, `$idMap($string).check` would
* pass just as happily on a producer that silently dropped every attribute.
*
* @param {t.TestCase} _tc
*/
export const testIdMapProducers = _tc => {
/**
* @type {Array<[string, () => IdMap<any>, boolean]>}
*/
const producers = [
['createIdMap', () => Y.createIdMap(), false],
['add', () => idMapOf(['k']), true],
['mergeIdMaps', () => Y.mergeIdMaps([idMapOf(['k']), idMapOf(['j'])]), true],
['diffIdMap (exclude all)', () => Y.diffIdMap(idMapOf(['k']), idSetOf()), false],
['diffIdMap (partial)', () => {
const exclude = Y.createIdSet()
exclude.add(1, 0, 5)
return Y.diffIdMap(idMapOf(['k']), exclude)
}, true],
['intersectMaps', () => Y.intersectMaps(idMapOf(['k']), idMapOf(['k'])), true],
['createIdMapFromIdSet', () => Y.createIdMapFromIdSet(idSetOf(), [attr('k')]), true],
['filterIdMap (keep)', () => Y.filterIdMap(idMapOf(['k']), () => true), true],
['filterIdMap (drop)', () => Y.filterIdMap(idMapOf(['k']), () => false), false],
['decodeIdMap(encodeIdMap)', () => Y.decodeIdMap(Y.encodeIdMap(idMapOf(['k']))), true],
['insertIntoIdMap(dest, IdMap)', () => {
const dest = Y.createIdMap()
Y.insertIntoIdMap(dest, idMapOf(['k']))
return dest
}, true]
]
producers.forEach(([name, produce, keepsValues]) => {
t.group(name, () => {
const m = produce()
t.assert(Y.$idMapAny.check(m), 'is an IdMap')
t.assert(Y.$idMap(s.$string).check(m), 'holds only string values (vacuously so when empty)')
if (keepsValues) {
t.assert(!Y.$idMap(s.$number).check(m), 'carries its value through, so it rejects $number')
}
})
})
}
/**
* `insertIntoIdMap`'s `src` may be an IdSet, which puts attribute-less `IdRanges` into an `IdMap`.
* `$idMap` must not throw on those - `IdRange#attrs` is `[]` by design, so they are vacuously fine.
*
* @param {t.TestCase} _tc
*/
export const testIdMapWithIdRanges = _tc => {
t.group('an IdSet merged into an IdMap', () => {
const dest = Y.createIdMap()
Y.insertIntoIdMap(dest, idSetOf())
t.assert(Y.$idMap(s.$string).check(dest), 'no attributes present, so nothing can violate $attrs')
t.assert(Y.$idMap(s.$number).check(dest))
})
t.group('a map holding both AttrRanges and IdRanges still checks its values', () => {
const dest = idMapOf(['k']) // client 1 -> AttrRanges
const other = Y.createIdSet()
other.add(2, 0, 10) // client 2 -> IdRanges
Y.insertIntoIdMap(dest, other)
t.assert(Y.$idMap(s.$string).check(dest))
t.assert(!Y.$idMap(s.$number).check(dest), 'client 1 still holds a string')
})
t.group('an IdRanges container that later receives AttrRanges on the SAME client', () => {
// `_insertIntoIdSet` picks the container class from whichever `src` reaches a client first, and
// its append branch copies elements without checking their class. So an IdSet-first / IdMap-
// second sequence leaves real AttrRanges inside an `IdRanges`. `IdRanges#everyAttr` must
// iterate rather than shortcut to `true`, or these attributes become invisible to `$idMap`.
const dest = Y.createIdMap()
const set = Y.createIdSet()
set.add(1, 0, 10)
Y.insertIntoIdMap(dest, set) // client 1 -> IdRanges container
Y.insertIntoIdMap(dest, idMapOf([42])) // same client -> AttrRange(42) appended into it
t.assert(/** @type {any} */ (dest.clients.get(1)).constructor.name === 'IdRanges', 'precondition: still an IdRanges')
t.assert(!Y.$idMap(s.$string).check(dest), 'the leaked number 42 must be seen')
t.assert(!Y.$idMap(s.$never).check(dest), '$never must reject a map that holds a value')
t.assert(Y.$idMap(s.$number).check(dest), 'and accepted for the matching schema')
t.group('the verdict agrees with an encode round-trip', () => {
const rt = Y.decodeIdMap(Y.encodeIdMap(dest))
t.assert(Y.$idMap(s.$string).check(rt) === Y.$idMap(s.$string).check(dest))
t.assert(Y.$idMap(s.$number).check(rt) === Y.$idMap(s.$number).check(dest))
})
})
}
/**
* @param {t.TestCase} _tc
*/
export const testNodeSchemas = _tc => {
const ydoc = new Y.Doc()
const unnamed = ydoc.get('unnamed')
const para = ydoc.get('para', 'p')
t.group('$nodeAny', () => {
t.assert(Y.$nodeAny.check(unnamed))
t.assert(Y.$nodeAny.check(para))
t.assert(!Y.$nodeAny.check(ydoc), 'a Doc is not a Node')
t.assert(!Y.$doc.check(para), 'a Node is not a Doc')
})
t.group('$node(conf) checks the name', () => {
t.assert(Y.$node({ name: 'p' }).check(para))
t.assert(!Y.$node({ name: 'h1' }).check(para), 'discriminates between named nodes')
})
t.group('a conf without a name accepts any node', () => {
t.assert(Y.$node({ text: true }).check(para))
t.assert(Y.$node({ text: true }).check(unnamed))
t.assert(Y.$node({}).check(unnamed))
})
t.group('every ReadableDeltaConf name form', () => {
t.assert(Y.$node({ name: ['p', 'h1'] }).check(para), 'array form')
t.assert(!Y.$node({ name: ['h1', 'h2'] }).check(para))
t.assert(Y.$node({ name: s.$string }).check(para), 'schema form')
})
t.group('a named conf rejects a nameless node', () => {
// Deliberately stricter than lib0's $Delta.check, which skips the name when name == null: a
// nameless *node* is definitively a fragment, whereas a nameless *delta* asserts nothing.
t.assert(unnamed.name === null, 'precondition')
t.assert(!Y.$node({ name: 'p' }).check(unnamed))
t.assert(!Y.$node({ name: s.$string }).check(unnamed))
})
}
/**
* The tags are interned in a global cross-install registry, so re-declaring a name returns the very
* same schema object. That is what makes `$type` sound across duplicate yjs installations, and what
* an `x.$type === $idMapAny` dispatch would rely on.
*
* Identity (`===`), never `.equals()` - `$Type#equals` compares an unset `.shape`, so any two
* `$Type`s compare equal.
*
* @param {t.TestCase} _tc
*/
export const testTagInterning = _tc => {
t.assert(s.$type('y:idSet', null) === Y.$idSet)
t.assert(s.$type('y:idMap', null) === Y.$idMapAny)
t.assert(s.$type('y:node', null) === Y.$nodeAny)
t.assert(s.$type('y:doc', null) === Y.$doc)
t.assert(s.$type('y:renderer', null) === Y.$renderer)
}
/**
* The prototype stamps are what `check()` reads.
*
* @param {t.TestCase} _tc
*/
export const testPrototypeStamps = _tc => {
t.assert(Y.IdSet.prototype.$type === Y.$idSet)
t.assert(Y.IdMap.prototype.$type === Y.$idMapAny)
t.assert(Y.Node.prototype.$type === Y.$nodeAny)
t.assert(Y.Doc.prototype.$type === Y.$doc)
t.group('instances carry the tag', () => {
t.assert(Y.createIdSet().$type === Y.$idSet)
t.assert(Y.createIdMap().$type === Y.$idMapAny)
t.assert(new Y.Doc().get('n').$type === Y.$nodeAny)
t.assert(new Y.Doc().$type === Y.$doc)
})
}

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

@@ -0,0 +1,244 @@
import * as Y from '../src/index.js'
import * as t from 'lib0/testing'
import { init } from './testHelper.js'
import * as delta from 'lib0/delta'
/**
* @param {t.TestCase} _tc
*/
export const testBasic = _tc => {
const ydoc = new Y.Doc({ gc: false })
ydoc.get().insert(0, 'world!')
const snapshot = Y.snapshot(ydoc)
ydoc.get().insert(0, 'hello ')
const restored = Y.createDocFromSnapshot(ydoc, snapshot)
t.assert(restored.get().toDelta().equals(delta.create().insert('world!').done()))
}
/**
* @param {t.TestCase} _tc
*/
export const testBasicXmlAttributes = _tc => {
const ydoc = new Y.Doc({ gc: false })
const yxml = ydoc.get().setAttr('el', new Y.Node('div'))
const snapshot1 = Y.snapshot(ydoc)
yxml.setAttr('a', '1')
const snapshot2 = Y.snapshot(ydoc)
yxml.setAttr('a', '2')
t.compare(yxml.getAttrs(), { a: '2' })
t.compare(yxml.getAttrs(snapshot2), { a: '1' })
t.compare(yxml.getAttrs(snapshot1), {})
}
/**
* @param {t.TestCase} _tc
*/
export const testBasicRestoreSnapshot = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, ['hello'])
const snap = Y.snapshot(doc)
doc.get('array').insert(1, ['world'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toArray(), ['hello'])
t.compare(doc.get('array').toJSON().children, ['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.get().insert(0, ['world'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get().toArray(), [])
t.compare(doc.get().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.get().toArray(), ['world'])
}
/**
* @param {t.TestCase} _tc
*/
export const testRestoreSnapshotWithSubType = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, [new Y.Node()])
const subMap = doc.get('array').get(0)
subMap.setAttr('key1', 'value1')
const snap = Y.snapshot(doc)
subMap.setAttr('key2', 'value2')
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toJSON().children, [{
attrs: {
key1: 'value1'
}
}])
t.compare(doc.get('array').toJSON().children, [{
attrs: {
key1: 'value1',
key2: 'value2'
}
}])
}
/**
* @param {t.TestCase} _tc
*/
export const testRestoreDeletedItem1 = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, ['item1', 'item2'])
const snap = Y.snapshot(doc)
doc.get('array').delete(0)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toArray(), ['item1', 'item2'])
t.compare(doc.get('array').toArray(), ['item2'])
}
/**
* @param {t.TestCase} _tc
*/
export const testRestoreLeftItem = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, ['item1'])
doc.get('map').setAttr('test', 1)
doc.get('array').insert(0, ['item0'])
const snap = Y.snapshot(doc)
doc.get('array').delete(1)
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toArray(), ['item0', 'item1'])
t.compare(doc.get('array').toArray(), ['item0'])
}
/**
* @param {t.TestCase} _tc
*/
export const testDeletedItemsBase = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, ['item1'])
doc.get('array').delete(0)
const snap = Y.snapshot(doc)
doc.get('array').insert(0, ['item0'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toArray(), [])
t.compare(doc.get('array').toArray(), ['item0'])
}
/**
* @param {t.TestCase} _tc
*/
export const testDeletedItems2 = _tc => {
const doc = new Y.Doc({ gc: false })
doc.get('array').insert(0, ['item1', 'item2', 'item3'])
doc.get('array').delete(1)
const snap = Y.snapshot(doc)
doc.get('array').insert(0, ['item0'])
const docRestored = Y.createDocFromSnapshot(doc, snap)
t.compare(docRestored.get('array').toArray(), ['item1', 'item3'])
t.compare(doc.get('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.get('array').toArray(), ['user1item1', 'user2item1'])
const docRestored1 = Y.createDocFromSnapshot(array1.doc, snap)
t.compare(docRestored1.get('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.get()
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]))
}
/**
* Reported by https://github.com/yjs/yjs/issues/767
* @param {t.TestCase} _tc
*/
export const testContainsUpdate2 = _tc => {
const local = new Y.Doc()
const remote = new Y.Doc()
local.get('t').insert(0, 'abcdefghij')
local.get('t').delete(0, 3)
Y.applyUpdate(remote, Y.encodeStateAsUpdate(local))
const snap = Y.snapshot(remote)
local.get('t').delete(0, 3)
const update = Y.encodeStateAsUpdate(local)
t.assert(!Y.snapshotContainsUpdate(snap, update))
}

595
tests/testHelper.js Normal file
View File

@@ -0,0 +1,595 @@
import * as Y from '../src/index.js'
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 math from 'lib0/math'
import * as list from 'lib0/list'
import * as delta from 'lib0/delta'
import { createIdSet, createIdMap, encodeIdMap } from '../src/utils/ids.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
}
/**
* @extends {Y.Doc}
*/
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<ArrayBuffer>>}
*/
this.updates = []
// set up observe on local model
this.on(enc.updateEventName, (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, /** @type {any} */ (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, /** @type {any} */ (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, /** @type {any} */ (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 random 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.Node<any>,array1:Y.Node<any>,array2:Y.Node<any>,map0:Y.Node<any>,map1:Y.Node<any>,map2:Y.Node<any>,map3:Y.Node<any>,text0:Y.Node,text1:Y.Node,text2:Y.Node,xml0:Y.Node,xml1:Y.Node,xml2:Y.Node}}
*/
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.get('array')
result['map' + i] = y.get('map')
result['xml' + i] = y.get('xml')
result['text' + i] = y.get('text')
}
testConnector.syncAll()
result.testObjects = result.users.map(initTestObject || (() => null))
useV1Encoding()
return /** @type {any} */ (result)
}
/**
* @param {Y.IdSet} idSet1
* @param {Y.IdSet} idSet2
*/
export const compareIdSets = (idSet1, idSet2) => {
t.assert(idSet1.clients.size === idSet2.clients.size)
for (const [client, _items1] of idSet1.clients.entries()) {
const items1 = _items1.getIds()
const items2 = idSet2.clients.get(client)?.getIds()
t.assert(items2 !== undefined && items1.length === items2.length)
for (let i = 0; i < items1.length; i++) {
const di1 = items1[i]
const di2 = /** @type {Array<import('../src/utils/ids.js').IdRange>} */ (items2)[i]
t.assert(di1.clock === di2.clock && di1.len === di2.len)
}
}
return true
}
/**
* only use for testing
*
* @template T
* @param {Array<Y.ContentAttribute<T>>} attrs
* @param {Y.ContentAttribute<T>} attr
*
*/
const _idmapAttrsHas = (attrs, attr) => {
const hash = attr.hash()
return attrs.find(a => a.hash() === hash)
}
/**
* only use for testing
*
* @template T
* @param {Array<Y.ContentAttribute<T>>} a
* @param {Array<Y.ContentAttribute<T>>} b
*/
export const _idmapAttrsEqual = (a, b) => a.length === b.length && a.every(v => _idmapAttrsHas(b, v))
/**
* Ensure that all attributes exist. Also create a copy and compare it to the original.
*
* @template T
* @param {Y.IdMap<T>} idmap
*/
export const validateIdMap = idmap => {
const copy = Y.createIdMap()
idmap.clients.forEach((ranges, client) => {
ranges.getIds().forEach(range => {
range.attrs.forEach(attr => {
t.assert(idmap.attrs.has(attr))
t.assert(idmap.attrsH.get(attr.hash()) === attr)
copy.add(client, range.clock, range.len, range.attrs.slice())
})
})
t.assert(copy.clients.get(client)?.getIds().length === ranges.getIds().length)
})
t.assert(idmap.attrsH.size === idmap.attrs.size)
}
/**
* @template T
* @param {Y.IdMap<T>} idmap1
* @param {Y.IdMap<T>} idmap2
*/
export const compareIdmaps = (idmap1, idmap2) => {
t.assert(idmap1.clients.size === idmap2.clients.size)
for (const [client, _items1] of idmap1.clients.entries()) {
const items1 = _items1.getIds()
const items2 = idmap2.clients.get(client)?.getIds()
t.assert(items2 !== undefined && items1.length === items2.length)
for (let i = 0; i < items1.length; i++) {
const di1 = items1[i]
const di2 = /** @type {Array<import('../src/utils/ids.js').AttrRange<T>>} */ (items2)[i]
t.assert(di1.clock === di2.clock && di1.len === di2.len && _idmapAttrsEqual(di1.attrs, di2.attrs))
}
}
validateIdMap(idmap1)
validateIdMap(idmap2)
}
/**
* @param {prng.PRNG} gen
* @param {number} clients
* @param {number} clockRange (max clock - exclusive - by each client)
*/
export const createRandomIdSet = (gen, clients, clockRange) => {
const maxOpLen = 5
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
const idset = createIdSet()
for (let i = 0; i < numOfOps; i++) {
const client = prng.uint32(gen, 0, clients - 1)
const clockStart = prng.uint32(gen, 0, clockRange)
const len = prng.uint32(gen, 0, clockRange - clockStart)
idset.add(client, clockStart, len)
}
if (idset.clients.size === clients && clients > 1 && prng.bool(gen)) {
idset.clients.delete(prng.uint32(gen, 0, clients))
}
return idset
}
/**
* @template T
* @param {prng.PRNG} gen
* @param {number} clients
* @param {number} clockRange (max clock - exclusive - by each client)
* @param {Array<T>} attrChoices (max clock - exclusive - by each client)
* @return {Y.IdMap<T>}
*/
export const createRandomIdMap = (gen, clients, clockRange, attrChoices) => {
const maxOpLen = 5
const numOfOps = math.ceil((clients * clockRange) / maxOpLen)
const idMap = createIdMap()
for (let i = 0; i < numOfOps; i++) {
const client = prng.uint32(gen, 0, clients - 1)
const clockStart = prng.uint32(gen, 0, clockRange)
const len = prng.uint32(gen, 0, clockRange - clockStart)
const attrs = [prng.oneOf(gen, attrChoices)]
// maybe add another attr
if (prng.bool(gen)) {
const a = prng.oneOf(gen, attrChoices)
if (attrs.find(attr => attr === a) == null) {
attrs.push(a)
}
}
idMap.add(client, clockStart, len, attrs.map(v => Y.createContentAttribute('', v)))
}
t.info(`Created IdMap with ${numOfOps} ranges and ${attrChoices.length} different attributes. Encoded size: ${encodeIdMap(idMap).byteLength}`)
return idMap
}
/**
* 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.get('array').toJSON().children ?? [])
const userMapValues = users.map(u => u.get('map').toJSON().attrs ?? {})
// @todo fix type error here
// @ts-ignore
const userXmlValues = users.map(u => u.get('xml').toString())
const userTextValues = users.map(u => u.get('text').toDeltaDeep())
for (const u of users) {
t.assert(u.store.pendingDs === null)
t.assert(u.store.pendingStructs === null)
}
// Test Map iterator
const ymapkeys = Array.from(users[0].get('map').attrKeys())
t.assert(ymapkeys.length === Object.keys(userMapValues[0]).length)
ymapkeys.forEach(key => t.assert(object.hasProperty(userMapValues[0], key)))
// Compare all users
for (let i = 0; i < users.length - 1; i++) {
t.compare(userArrayValues[i].length, users[i].get('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(list.toArray(userTextValues[i].children).map(a => (delta.$textOp.check(a) || delta.$insertOp.check(a)) ? a.insert.length : 0).reduce((a, b) => a + b, 0), users[i].get('text').length)
t.compare(userTextValues[i], userTextValues[i + 1], '', (_constructor, a, b) => {
if (a instanceof Y.Node) {
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.equalIdSets(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.forEach(user => {
compareIdSets(user.store.ds, Y.createDeleteSetFromStructStore(user.store))
})
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 {StructStore} ss1
* @param {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
}
/**
* @typedef {ReturnType<typeof applyRandomTests>} ApplyRandomTestsResult
*/

825
tests/undo-redo.tests.js Normal file
View File

@@ -0,0 +1,825 @@
import * as Y from '../src/index.js'
import { init } from './testHelper.js' // eslint-disable-line
import * as t from 'lib0/testing'
import * as delta from 'lib0/delta'
export const testInconsistentFormat = () => {
/**
* @param {Y.Doc} ydoc
*/
const testYjsMerge = ydoc => {
const content = ydoc.get('text')
content.format(0, 6, { bold: null })
content.format(6, 4, { type: 'text' })
t.compare(content.toDelta(), delta.create().insert('Merge Test', { type: 'text' }).insert(' After', { type: 'text', italic: true }).done())
}
const initializeYDoc = () => {
const yDoc = new Y.Doc({ gc: false })
const content = yDoc.get('text')
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(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
undoManager.undo()
t.compare(text0.toDelta(), delta.create().insert('bcxyz').done())
undoManager.redo()
t.compare(text0.toDelta(), delta.create().insert('b').insert('cxy', { bold: true }).insert('z').done())
}
/**
* 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.get()
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.get('restricted').insert(0, [1])
tmpydoc1.get('public').insert(0, [1])
const update1 = Y.encodeStateAsUpdate(tmpydoc1)
const tmpydoc2 = new Y.Doc()
tmpydoc2.get('public').insert(0, [2])
const update2 = Y.encodeStateAsUpdate(tmpydoc2)
const ydoc = new Y.Doc()
const restrictedType = ydoc.get('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.get('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.get()
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.get()
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.setAttr('a', 0)
const undoManager = new Y.UndoManager(map0)
map0.setAttr('a', 1)
undoManager.undo()
t.assert(map0.getAttr('a') === 0)
undoManager.redo()
t.assert(map0.getAttr('a') === 1)
// testing sub-types and if it can restore a whole type
const subType = new Y.Node()
map0.setAttr('a', subType)
subType.setAttr('x', 42)
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
undoManager.undo()
t.assert(map0.getAttr('a') === 1)
undoManager.redo()
t.compare(map0.toJSON().attrs, /** @type {any} */ ({ a: { attrs: { x: 42 } } }))
testConnector.syncAll()
// if content is overwritten by another user, undo operations should be skipped
map1.setAttr('a', 44)
testConnector.syncAll()
undoManager.undo()
t.assert(map0.getAttr('a') === 44)
undoManager.redo()
t.assert(map0.getAttr('a') === 44)
// test setting value multiple times
map0.setAttr('b', 'initial')
undoManager.stopCapturing()
map0.setAttr('b', 'val1')
map0.setAttr('b', 'val2')
undoManager.stopCapturing()
undoManager.undo()
t.assert(map0.getAttr('b') === 'initial')
}
/**
* Regression: undoing a deletion of an embedded type together with an attribute
* change on it must restore the attribute consistently for remote peers. The
* redone attribute item used to keep an origin from the original (deleted)
* parent, so the value was correct locally but dropped after sync.
*
* @param {t.TestCase} tc
*/
export const testUndoEmbeddedTypeAttribute = tc => {
const { testConnector, text0, text1 } = init(tc, { users: 2 })
const button = new Y.Node()
button.setAttr('type', 'button')
button.setAttr('test', true)
button.insert(0, 'Click me')
text0.insert(0, [button])
testConnector.syncAll()
const undoManager = new Y.UndoManager(text0)
undoManager.stopCapturing()
// change an attribute, then delete the embedded type
button.setAttr('type', 'paragraph')
text0.delete(0, 1)
// undo both operations at once
undoManager.undo()
const expected = { type: 'button', test: true }
t.compare(text0.get(0).getAttrs(), expected)
testConnector.syncAll()
// remote peer must converge to the same attributes
t.compare(text1.get(0).getAttrs(), expected)
}
/**
* @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.Node()
array0.insert(0, [ymap])
t.compare(array0.toJSON().children, [{}])
undoManager.stopCapturing()
ymap.setAttr('a', 1)
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
undoManager.undo()
t.compare(array0.toJSON().children, [{}])
undoManager.undo()
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON().children, [{}])
undoManager.redo()
t.compare(array0.toJSON().children, [{ attrs: { a: 1 } }])
testConnector.syncAll()
array1.get(0).setAttr('b', 2)
testConnector.syncAll()
t.compare(array0.toJSON().children, [{ attrs: { a: 1, b: 2 } }])
undoManager.undo()
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
undoManager.undo()
t.compare(array0.toJSON().children, [2, 3, 4, 5, 6])
undoManager.redo()
t.compare(array0.toJSON().children, [{ attrs: { b: 2 } }])
undoManager.redo()
t.compare(array0.toJSON().children, [{ attrs: { 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.Node('p')
xml0.insert(0, [child])
const textchild = Y.Node.from(delta.create().insert('content'))
child.insert(0, [textchild])
// format textchild and revert that change
undoManager.stopCapturing()
textchild.format(3, 4, { bold: true })
const v1 = delta.create().insert([delta.create('p').insert([delta.create().insert('con').insert('tent', { bold: true }).done()]).done()]).done()
const v2 = delta.create().insert([delta.create('p').insert([delta.create().insert('content').done()]).done()]).done()
t.compare(xml0.toDeltaDeep(), v1)
undoManager.undo()
t.compare(xml0.toDeltaDeep(), v2)
undoManager.redo()
t.compare(xml0.toDeltaDeep(), v1)
xml0.delete(0, 1)
t.compare(xml0.toDeltaDeep(), delta.create().done())
undoManager.undo()
t.compare(xml0.toDeltaDeep(), v1)
}
/**
* @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.Node()
const text1 = new Y.Node()
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 = Y.Node.from(delta.create().insert('initial text'))
undoManager.stopCapturing()
text0.insert(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 => {
const array0 = 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.Node()
map0.setAttr('hi', 1)
const map1 = new Y.Node()
array0.insert(0, [map0, map1])
undoManager.undo()
t.assert(array0.length === 1)
array0.get(0)
t.assert(Array.from(array0.get(0).attrKeys()).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.get('array')
const yArray2 = doc2.get('array')
const yMap = new Y.Node()
yMap.setAttr('hello', 'world')
yArray.push([yMap])
const yMap2 = new Y.Node()
yMap2.setAttr('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.setAttr('key', 'value modified'), doc.clientID)
undoManager.stopCapturing()
Y.transact(doc, () => yMap.setAttr('hello', 'world modified'), doc.clientID)
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
undoManager2.undo()
undoManager.undo()
t.compareStrings(yMap2.getAttr('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.get()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
const text = new Y.Node()
const blocks1 = new Y.Node()
const blocks1block = new Y.Node()
doc.transact(() => {
blocks1block.setAttr('text', 'Type Something')
blocks1.push([blocks1block])
text.setAttr('blocks', blocks1block)
design.setAttr('text', text)
})
const blocks2 = new Y.Node()
const blocks2block = new Y.Node()
doc.transact(() => {
blocks2block.setAttr('text', 'Something')
blocks2.push([blocks2block])
text.setAttr('blocks', blocks2block)
})
const blocks3 = new Y.Node()
const blocks3block = new Y.Node()
doc.transact(() => {
blocks3block.setAttr('text', 'Something Else')
blocks3.push([blocks3block])
text.setAttr('blocks', blocks3block)
})
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something Else' }) }) }))
undoManager.undo()
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
undoManager.undo()
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
undoManager.undo()
t.compare(design.toDeltaDeep(), delta.from({}))
undoManager.redo()
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Type Something' }) }) }))
undoManager.redo()
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ text: 'Something' }) }) }))
undoManager.redo()
t.compare(design.toDeltaDeep(), delta.from({ text: delta.from({ blocks: delta.from({ 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.get()
const undoMgr = new Y.UndoManager(yRoot)
let yPoint = new Y.Node()
yPoint.setAttr('x', 0)
yPoint.setAttr('y', 0)
yRoot.setAttr('a', yPoint)
undoMgr.stopCapturing()
yPoint.setAttr('x', 100)
yPoint.setAttr('y', 100)
undoMgr.stopCapturing()
yPoint.setAttr('x', 200)
yPoint.setAttr('y', 200)
undoMgr.stopCapturing()
yPoint.setAttr('x', 300)
yPoint.setAttr('y', 300)
undoMgr.stopCapturing()
t.compare(yPoint.toJSON().attrs, { x: 300, y: 300 })
undoMgr.undo() // x=200, y=200
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
undoMgr.undo() // x=100, y=100
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
undoMgr.undo() // x=0, y=0
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
undoMgr.undo() // nil
t.compare(yRoot.getAttr('a'), undefined)
undoMgr.redo() // x=0, y=0
yPoint = yRoot.getAttr('a')
t.compare(yPoint.toJSON().attrs, { x: 0, y: 0 })
undoMgr.redo() // x=100, y=100
t.compare(yPoint.toJSON().attrs, { x: 100, y: 100 })
undoMgr.redo() // x=200, y=200
t.compare(yPoint.toJSON().attrs, { x: 200, y: 200 })
undoMgr.redo() // expected x=300, y=300, actually nil
t.compare(yPoint.toJSON().attrs, { 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.get('t')
const undoManager = new Y.UndoManager(fragment, {
captureTimeout: 0,
trackedOrigins: new Set([origin])
})
// create element
doc.transact(() => {
const e = new Y.Node('test-node')
e.setAttr('a', '100')
e.setAttr('b', '0')
fragment.insert(fragment.length, [e])
}, origin)
// change one attribute
doc.transact(() => {
const e = fragment.get(0)
e.setAttr('a', '200')
}, origin)
// change both attributes
doc.transact(() => {
const e = fragment.get(0)
e.setAttr('a', '180')
e.setAttr('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" />')
}
/**
* 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.get()
const undoManager = new Y.UndoManager(design, { captureTimeout: 0 })
const text = new Y.Node()
const blocks1 = new Y.Node()
const blocks1block = new Y.Node()
doc.transact(() => {
blocks1block.setAttr('text', '1')
blocks1.push([blocks1block])
text.setAttr('blocks', blocks1block)
design.setAttr('text', text)
})
const blocks2 = new Y.Node()
const blocks2block = new Y.Node()
doc.transact(() => {
blocks2block.setAttr('text', '2')
blocks2.push([blocks2block])
text.setAttr('blocks', blocks2block)
})
const blocks3 = new Y.Node()
const blocks3block = new Y.Node()
doc.transact(() => {
blocks3block.setAttr('text', '3')
blocks3.push([blocks3block])
text.setAttr('blocks', blocks3block)
})
const blocks4 = new Y.Node()
const blocks4block = new Y.Node()
doc.transact(() => {
blocks4block.setAttr('text', '4')
blocks4.push([blocks4block])
text.setAttr('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().attrs, { text: { attrs: { blocks: { attrs: { 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.get()
text.insert(0, 'Attack ships on fire off the shoulder of Orion.')
const doc2 = new Y.Doc()
const text2 = doc2.get()
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 = delta.create()
.insert('Attack ships ')
.insert('on fire', { 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 testBehaviorOfIgnoreRemoteAttributeChangesProperty = _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.get()
const map2 = doc2.get()
const um1 = new Y.UndoManager(map1, { ignoreRemoteAttributeChanges: true })
map1.setAttr('x', 1)
map2.setAttr('x', 2)
map1.setAttr('x', 3)
map2.setAttr('x', 4)
um1.undo()
t.assert(map1.getAttr('x') === 2)
t.assert(map2.getAttr('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.get()
const undoManager = new Y.UndoManager(fragment, { trackedOrigins: new Set([origin]) })
doc.transact(() => {
const e = new Y.Node('test')
e.setAttr('a', '1')
e.setAttr('b', '2')
fragment.insert(0, [e])
})
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
doc.transact(() => {
// change attribute "b" and delete test-node
const e = fragment.get(0)
e.setAttr('b', '3')
fragment.delete(0)
}, origin)
t.compareStrings(fragment.toString(), '')
undoManager.undo()
t.compareStrings(fragment.toString(), '<test a="1" b="2" />')
}
/**
* 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.setAttr('a', 'a')
map0.deleteAttr('a')
map0.setAttr('a', 'b')
map0.deleteAttr('a')
map0.setAttr('a', 'c')
map0.deleteAttr('a')
map0.setAttr('a', 'd')
t.compare(map0.toJSON().attrs, { a: 'd' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON().attrs, { a: 'c' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON().attrs, { a: 'b' })
undoManager.undo()
t.compare(map0.toJSON(), {})
undoManager.undo()
t.compare(map0.toJSON().attrs, { 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.get('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')
}

461
tests/updates.tests.js Normal file
View File

@@ -0,0 +1,461 @@
import * as t from 'lib0/testing'
import * as Y from '../src/index.js'
import { init, compare } from './testHelper.js' // eslint-disable-line
import { readBlockSet } from '../src/utils/BlockSet.js'
import { readIdSet, writeIdSet } from '../src/utils/ids.js'
import { UpdateDecoderV2 } from '../src/utils/UpdateDecoder.js'
import { UpdateEncoderV2 } from '../src/utils/UpdateEncoder.js'
import * as encoding from 'lib0/encoding'
import * as decoding from 'lib0/decoding'
import * as object from 'lib0/object'
import * as delta from 'lib0/delta'
import * as array from 'lib0/array'
/**
* @typedef {Object} Enc
* @property {function(Array<Uint8Array<ArrayBuffer>>):Uint8Array<ArrayBuffer>} Enc.mergeUpdates
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateAsUpdate
* @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate
* @property {function(Uint8Array):void} Enc.logUpdate
* @property {function(Uint8Array):{deletes:Y.IdSet,inserts:Y.IdSet}} Enc.readUpdateToContentIds
* @property {function(Y.Doc):Uint8Array<ArrayBuffer>} Enc.encodeStateVector
* @property {function(Uint8Array):Uint8Array<ArrayBuffer>} Enc.encodeStateVectorFromUpdate
* @property {'update'|'updateV2'} Enc.updateEventName
* @property {string} Enc.description
* @property {function(Uint8Array<ArrayBuffer>, Uint8Array<ArrayBuffer>):Uint8Array<ArrayBuffer>} Enc.diffUpdate
*/
/**
* @type {Enc}
*/
const encV1 = {
mergeUpdates: Y.mergeUpdates,
encodeStateAsUpdate: Y.encodeStateAsUpdate,
applyUpdate: Y.applyUpdate,
logUpdate: Y.logUpdate,
readUpdateToContentIds: Y.createContentIdsFromUpdate,
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,
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
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,
readUpdateToContentIds: Y.createContentIdsFromUpdateV2,
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.get('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)
const c = text1.toDelta()
t.compare(
c,
delta.create()
.insert('c', { italic: true })
.insert('b')
.insert('a', { italic: true })
.done()
)
compare(users)
}
/**
* @param {Y.Doc} ydoc
* @param {Array<Uint8Array<ArrayBuffer>>} 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, i) => {
t.info(`State Case $${i} (${enc.description}):`)
// enc.logUpdate(updates)
const merged = new Y.Doc({ gc: false })
enc.applyUpdate(merged, mergedUpdates)
t.compareArrays(merged.get().toArray(), ydoc.get().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.readUpdateToContentIds(partMerged)
const targetSV = enc.encodeStateVectorFromUpdate(enc.mergeUpdates(updates.slice(0, j)))
const diffed = enc.diffUpdate(mergedUpdates, targetSV)
const diffedMeta = enc.readUpdateToContentIds(diffed)
t.compare(partMeta.inserts, diffedMeta.inserts)
{
// 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)
readBlockSet(updateDecoder)
const ds = readIdSet(updateDecoder)
const updateEncoder = new UpdateEncoderV2()
encoding.writeVarUint(updateEncoder.restEncoder, 0) // 0 structs
writeIdSet(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.readUpdateToContentIds(mergedUpdates)
meta.inserts.clients.forEach(range => { t.assert(range.getIds()[0].clock === 0) })
meta.inserts.clients.forEach((range, client) => {
const structs = /** @type {Array<Y.Item>} */ (merged.store.clients.get(client))
const lastStruct = structs[structs.length - 1]
const lastIdRange = array.last(range.getIds())
t.assert(lastStruct.id.clock + lastStruct.length === lastIdRange.clock + lastIdRange.len)
})
})
}
/**
* @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<ArrayBuffer>>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.get()
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<ArrayBuffer>>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.get()
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 testMergeUpdatesStressTest = _tc => {
const N = 100
const M = 100
encoders.forEach((enc, _i) => {
t.info(`Using encoder: ${enc.description}`)
const ydoc = new Y.Doc({ gc: false })
const updates = /** @type {Array<Uint8Array<ArrayBuffer>>} */ ([])
ydoc.on(enc.updateEventName, update => { updates.push(update) })
const array = ydoc.get()
for (let clientid = 0; clientid < N; clientid++) {
ydoc.clientID = clientid
for (let i = 0; i < M; i++) {
array.push([i])
}
}
t.measureTime('merge via Y.mergeUpdates', () => {
enc.mergeUpdates(updates)
})
t.measureTime('merge via Y.applyUpdate on Y.Doc', () => {
const ydoc = new Y.Doc()
updates.forEach(update => {
enc.applyUpdate(ydoc, update)
})
})
})
}
/**
* @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.get('textBlock')
yText.applyDelta(delta.create().insert('r').done())
yText.applyDelta(delta.create().insert('o').done())
yText.applyDelta(delta.create().insert('n').done())
yText.applyDelta(delta.create().insert('e').done())
yText.applyDelta(delta.create().insert('n').done())
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])
Y.encodeStateAsUpdate(yDoc5)
const yText5 = yDoc5.get('textBlock')
t.compareStrings(yText5.toString(), 'nenor')
}
/**
* @param {t.TestCase} _tc
*/
export const testObfuscateUpdates = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get('text')
const ymap = ydoc.get('map')
const yarray = ydoc.get('array')
// test ytext
ytext.applyDelta(delta.create().insert('text', { bold: true }).insert([{ href: 'supersecreturl' }]).done())
// test ymap
ymap.setAttr('key', 'secret1')
ymap.setAttr('key', 'secret2')
// test yarray with subtype & subdoc
const subtype = new Y.Node('secretnodename')
const subdoc = new Y.Doc({ guid: 'secret' })
subtype.setAttr('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.get('text')
const omap = odoc.get('map')
const oarray = odoc.get('array')
// test ytext
const d = /** @type {any} */ (otext.toDelta().toJSON().children)
t.assert(d.length === 2)
t.assert(d[0].insert !== 'text' && d[0].insert.length === 4)
t.assert(object.length(d[0].format) === 1)
t.assert(!object.hasProperty(d[0].format, 'bold'))
t.assert(object.length(d[1].insert) === 1)
t.assert(object.hasProperty(d[1], 'insert'))
// test ymap
t.assert(omap.attrSize === 1)
t.assert(!omap.hasAttr('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.Node} */ (result[2])
const osubdoc = result[3]
// test subtype
t.assert(osubtype.name !== subtype.name)
t.assert(object.length(osubtype.getAttrs()) === 1)
t.assert(osubtype.getAttr('attr') === undefined)
// test subdoc
t.assert(osubdoc.guid !== subdoc.guid)
}
export const testIntersectDoc = () => {
const ydoc = new Y.Doc()
ydoc.get().setAttr('k', 1)
const c1 = Y.createContentIdsFromDoc(ydoc, true)
ydoc.get().setAttr('k', 2)
const v1 = Y.intersectUpdateWithContentIds(Y.encodeStateAsUpdate(ydoc), c1)
const y1 = new Y.Doc()
Y.applyUpdate(y1, v1)
t.assert(ydoc.get().getAttr('k'))
}
/**
* Selecting a sparse mid-stream range of content-ids must not drop the selection
* just because earlier structs of the same client were not selected.
*
* @see https://github.com/yjs/yjs/issues/781
*/
export const testIntersectSparseContentIds = () => {
const src = new Y.Doc()
src.transact(() => {
const m = src.get('m')
for (let i = 0; i < 10; i++) m.setAttr(`k${i}`, i) // clocks 0..9, single client
})
const update = Y.encodeStateAsUpdate(src)
const cids = Y.createContentIdsFromUpdate(update)
/**
* @type {number}
*/
let client = 0
cids.inserts.clients.forEach((_r, c) => { client = c })
// Select ONLY clocks [5, 7) — a mid-stream range.
const sel = Y.createIdSet()
sel.add(client, 5, 2)
const chunk = Y.intersectUpdateWithContentIds(update, {
inserts: sel,
deletes: Y.createIdSet()
})
// Each map key is a separate length-1 item, so clocks [5,7) yield two structs.
const structs = Y.decodeUpdate(chunk).structs.filter(s => !(s instanceof Y.Skip))
t.assert(structs.every(s => s.id.client === client))
t.compare(structs.map(s => [s.id.clock, s.length]), [[5, 1], [6, 1]])
// A selection split across a gap must round-trip both ranges with a Skip in between.
const sel2 = Y.createIdSet()
sel2.add(client, 1, 2) // clocks 1,2
sel2.add(client, 7, 1) // clock 7
const chunk2 = Y.intersectUpdateWithContentIds(update, {
inserts: sel2,
deletes: Y.createIdSet()
})
const structs2 = Y.decodeUpdate(chunk2).structs.filter(s => !(s instanceof Y.Skip))
t.compare(structs2.map(s => [s.id.clock, s.length]), [[1, 1], [2, 1], [7, 1]])
// A full-coverage selection must round-trip byte-identically to the source update.
const selAll = Y.createIdSet()
cids.inserts.clients.forEach((ranges, c) => {
ranges.getIds().forEach(r => selAll.add(c, r.clock, r.len))
})
const chunkAll = Y.intersectUpdateWithContentIds(update, {
inserts: selAll,
deletes: Y.createIdSet()
})
t.compare(chunkAll, update)
}

666
tests/y-array.tests.js Normal file
View File

@@ -0,0 +1,666 @@
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'
import * as delta from 'lib0/delta'
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.get('array').insert(0, ['hi'])
const update = Y.encodeStateAsUpdate(doc1)
Y.applyUpdate(doc2, update)
t.compare(doc2.get('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.get('test').insert(0, [a])
doc.get('map').setAttr('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.get('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.get('root')
const nestedArray1 = Y.Node.from(delta.create().insert([0, 1, 2]))
db1.setAttr('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.get('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.get()
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.toDelta(), delta.create().insert([1, true, false]).done(), 'content works')
testConnector.flushAllMessages()
t.compare(array1.toDelta(), delta.create().insert([1, true, false]).done(), 'comparison 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().children, ['x', 'user0', 'y'])
t.compare(array1.toJSON().children, ['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(() => {
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.Node()])
t.assert(event !== null)
event = null
array0.delete(0)
t.assert(event !== null)
event = null
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testChangeEvent = tc => {
const { array0, users } = init(tc, { users: 2 })
/**
* @type {delta.Delta<any>}
*/
let d = delta.create()
array0.observe(e => {
d = e.delta
})
const newArr = new Y.Node()
array0.insert(0, [newArr, 4, 'dtrn'])
t.assert(d !== null && d.children.len === 1)
t.compare(d, delta.create().insert([newArr, 4, 'dtrn']).done())
array0.delete(0, 2)
t.assert(d !== null && d.children.len === 1)
t.compare(d.toJSON().children, [{ delete: 2 }])
array0.insert(1, [0.1])
t.assert(d !== null && d.children.len === 2)
t.compare(d, delta.create().retain(1).insert([0.1]).done())
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.Node()])
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.Node()
newMap.observe(() => {
fired = true
})
array0.insert(0, [newMap])
newMap.setAttr('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.get('arr')
const numItems = 10
for (let i = 0; i < numItems; i++) {
const map = new Y.Node()
map.setAttr('value', i)
arr.push([map])
}
let cnt = 0
for (const item of arr.toArray()) {
t.assert(item.getAttr('value') === cnt++, 'value is correct')
}
y.destroy()
}
/**
* @param {t.TestCase} _tc
*/
export const testAttributedContent = _tc => {
const ydoc = new Y.Doc({ gc: false })
/**
* @type {Y.Node<{ children: number }>}
*/
const yarray = ydoc.get()
yarray.insert(0, [1, 2])
let renderer = /** @type {AbstractRenderer?} */ (null)
ydoc.on('afterTransaction', tr => {
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
})
t.group('insert / delete', () => {
ydoc.transact(() => {
yarray.delete(0, 1)
yarray.insert(1, [42])
})
const expectedContent = delta.create().insert([1], null, { delete: [] }).insert([2]).insert([42], null, { insert: [] })
const attributedContent = yarray.toDelta({ renderer })
console.log(attributedContent.toJSON())
t.assert(attributedContent.equals(expectedContent))
})
}
let _uniqueNumber = 0
const getUniqueNumber = () => _uniqueNumber++
/**
* @type {Array<function(Doc,prng.PRNG,any):void>}
*/
const arrayTransactions = [
function insert (user, gen) {
const yarray = user.get('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.get('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Node()])
const array2 = yarray.get(pos)
array2.insert(0, [1, 2, 3, 4])
},
function insertTypeMap (user, gen) {
const yarray = user.get('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [new Y.Node()])
const map = yarray.get(pos)
map.setAttr('someprop', 42)
map.setAttr('someprop', 43)
map.setAttr('someprop', 44)
},
function insertTypeNull (user, gen) {
const yarray = user.get('array')
const pos = prng.int32(gen, 0, yarray.length)
yarray.insert(pos, [null])
},
function _delete (user, gen) {
const yarray = user.get('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.Node && 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)
}

727
tests/y-map.tests.js Normal file
View File

@@ -0,0 +1,727 @@
import * as Y from '../src/index.js'
import { init, compare, applyRandomTests, Doc } from './testHelper.js' // eslint-disable-line
import { AttributionsRenderer } from '../src/utils/Renderer.js'
import { createIdMapFromIdSet } from '../src/utils/ids.js'
import * as t from 'lib0/testing'
import * as prng from 'lib0/prng'
import * as delta from 'lib0/delta'
import * as s from 'lib0/schema'
import * as object from 'lib0/object'
/**
* @param {t.TestCase} _tc
*/
export const testIterators = _tc => {
const ydoc = new Y.Doc()
/**
* @type {Y.Node<{attrs: { [k:string]: number} }>}
*/
const ymap = ydoc.get()
// we are only checking if the type assumptions are correct
/**
* @type {Array<number>}
*/
const vals = Array.from(ymap.attrValues())
/**
* @type {Array<[string,number]>}
*/
const entries = Array.from(ymap.attrEntries())
/**
* @type {Array<string>}
*/
const keys = Array.from(ymap.attrKeys())
console.log(vals, entries, keys)
}
export const testNestedMapEvent = () => {
const ydoc = new Y.Doc()
const ymap = ydoc.get()
const ymapNested = ymap.setAttr('nested', new Y.Node())
let called = 0
ymap.observeDeep(event => {
const d = event.deltaDeep
called++
t.compare(d, delta.create().modifyAttr('nested', delta.create().setAttr('k', 'v')).done())
})
ymapNested.setAttr('k', 'v')
t.assert(called === 1)
}
export const testNestedMapEvent2 = () => {
const ydoc = new Y.Doc()
const yarr = ydoc.get()
const ymapNested = new Y.Node()
yarr.insert(0, [ymapNested])
let called = 0
yarr.observeDeep(event => {
const d = event.deltaDeep
called++
t.compare(d, delta.create().modify(delta.create().setAttr('k', 'v')))
})
ymapNested.setAttr('k', 'v')
t.assert(called === 1)
}
/**
* 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.get()
/**
* @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 = Y.Node.from(delta.create().setAttr('number', 1).setAttr('string', 'hello'))
map0.setAttr('m1', m1)
t.assert(m1.getAttr('number') === 1)
t.assert(m1.getAttr('string') === 'hello')
const m2 = Y.Node.from(delta.create(delta.$deltaAny).setAttrs({ object: { x: 1 }, boolean: true }).done())
map0.setAttr('m2', m2)
t.assert(m2.getAttr('object')?.x === 1)
t.assert(m2.getAttr('boolean') === true)
const m3 = new Y.Node()
m3.applyDelta(m1.toDelta())
m3.applyDelta(m2.toDelta())
map0.setAttr('m3', m3)
t.assert(m3.getAttr('number') === 1)
t.assert(m3.getAttr('string') === 'hello')
t.assert(m3.getAttr('object')?.x === 1)
t.assert(m3.getAttr('boolean') === true)
}
/**
* @param {t.TestCase} tc
*/
export const testBasicMapTests = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
users[2].disconnect()
map0.setAttr('null', null)
map0.setAttr('number', 1)
map0.setAttr('string', 'hello Y')
map0.setAttr('object', { key: { key2: 'value' } })
map0.setAttr('y-map', new Y.Node())
map0.setAttr('boolean1', true)
map0.setAttr('boolean0', false)
const map = map0.getAttr('y-map')
map.setAttr('y-array', new Y.Node())
const array = map.getAttr('y-array')
array.insert(0, [0])
array.insert(0, [-1])
t.assert(map0.getAttr('null') === null, 'client 0 computed the change (null)')
t.assert(map0.getAttr('number') === 1, 'client 0 computed the change (number)')
t.assert(map0.getAttr('string') === 'hello Y', 'client 0 computed the change (string)')
t.assert(map0.getAttr('boolean0') === false, 'client 0 computed the change (boolean)')
t.assert(map0.getAttr('boolean1') === true, 'client 0 computed the change (boolean)')
t.compare(map0.getAttr('object'), { key: { key2: 'value' } }, 'client 0 computed the change (object)')
t.assert(map0.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 0 computed the change (type)')
t.assert(map0.attrSize === 7, 'client 0 map has correct size')
users[2].connect()
testConnector.flushAllMessages()
t.assert(map1.getAttr('null') === null, 'client 1 received the update (null)')
t.assert(map1.getAttr('number') === 1, 'client 1 received the update (number)')
t.assert(map1.getAttr('string') === 'hello Y', 'client 1 received the update (string)')
t.assert(map1.getAttr('boolean0') === false, 'client 1 computed the change (boolean)')
t.assert(map1.getAttr('boolean1') === true, 'client 1 computed the change (boolean)')
t.compare(map1.getAttr('object'), { key: { key2: 'value' } }, 'client 1 received the update (object)')
t.assert(map1.getAttr('y-map').getAttr('y-array').get(0) === -1, 'client 1 received the update (type)')
t.assert(map1.attrSize === 7, 'client 1 map has correct size')
// compare disconnected user
t.assert(map2.getAttr('null') === null, 'client 2 received the update (null) - was disconnected')
t.assert(map2.getAttr('number') === 1, 'client 2 received the update (number) - was disconnected')
t.assert(map2.getAttr('string') === 'hello Y', 'client 2 received the update (string) - was disconnected')
t.assert(map2.getAttr('boolean0') === false, 'client 2 computed the change (boolean)')
t.assert(map2.getAttr('boolean1') === true, 'client 2 computed the change (boolean)')
t.compare(map2.getAttr('object'), { key: { key2: 'value' } }, 'client 2 received the update (object) - was disconnected')
t.assert(map2.getAttr('y-map').getAttr('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.setAttr('stuff', 'stuffy')
map0.setAttr('undefined', undefined)
map0.setAttr('null', null)
t.compare(map0.getAttr('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.compare(u.getAttr('stuff'), 'stuffy')
t.assert(u.getAttr('undefined') === undefined, 'undefined')
t.compare(u.getAttr('null'), null, 'null')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYmap = tc => {
const { users, map0 } = init(tc, { users: 2 })
const map = map0.setAttr('Map', new Y.Node())
t.assert(map0.getAttr('Map') === map)
map.setAttr('one', 1)
t.compare(map.getAttr('one'), 1)
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testYmapSetsYarray = tc => {
const { users, map0 } = init(tc, { users: 2 })
const array = map0.setAttr('Array', new Y.Node())
t.assert(array === map0.getAttr('Array'))
array.insert(0, [1, 2, 3])
// @ts-ignore
t.compare(map0.toJSON().attrs, { Array: { children: [1, 2, 3] } })
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertySyncs = tc => {
const { testConnector, users, map0 } = init(tc, { users: 2 })
map0.setAttr('stuff', 'stuffy')
t.compare(map0.getAttr('stuff'), 'stuffy')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.compare(u.getAttr('stuff'), 'stuffy')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithConflict = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.setAttr('stuff', 'c0')
map1.setAttr('stuff', 'c1')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.compare(u.getAttr('stuff'), 'c1')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSizeAndDeleteOfMapProperty = tc => {
const { map0 } = init(tc, { users: 1 })
map0.setAttr('stuff', 'c0')
map0.setAttr('otherstuff', 'c1')
t.assert(map0.attrSize === 2, `map size is ${map0.attrSize} expected 2`)
map0.deleteAttr('stuff')
t.assert(map0.attrSize === 1, `map size after delete is ${map0.attrSize}, expected 1`)
map0.deleteAttr('otherstuff')
t.assert(map0.attrSize === 0, `map size after delete is ${map0.attrSize}, expected 0`)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapProperty = tc => {
const { testConnector, users, map0, map1 } = init(tc, { users: 3 })
map0.setAttr('stuff', 'c0')
map1.setAttr('stuff', 'c1')
map1.deleteAttr('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.assert(u.getAttr('stuff') === undefined)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapProperties = tc => {
const { testConnector, users, map0 } = init(tc, { users: 1 })
map0.setAttr('stuff', 'c0')
map0.setAttr('otherstuff', 'c1')
map0.clearAttrs()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.assert(u.getAttr('stuff') === undefined)
t.assert(u.getAttr('otherstuff') === undefined)
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testSetAndClearOfMapPropertiesWithConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.setAttr('stuff', 'c0')
map1.setAttr('stuff', 'c1')
map1.setAttr('stuff', 'c2')
map2.setAttr('stuff', 'c3')
testConnector.flushAllMessages()
map0.setAttr('otherstuff', 'c0')
map1.setAttr('otherstuff', 'c1')
map2.setAttr('otherstuff', 'c2')
map3.setAttr('otherstuff', 'c3')
map3.clearAttrs()
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.assert(u.getAttr('stuff') === undefined)
t.assert(u.getAttr('otherstuff') === undefined)
t.assert(u.attrSize === 0, `map size after clear is ${u.attrSize}, expected 0`)
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2 } = init(tc, { users: 3 })
map0.setAttr('stuff', 'c0')
map1.setAttr('stuff', 'c1')
map1.setAttr('stuff', 'c2')
map2.setAttr('stuff', 'c3')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.compare(u.getAttr('stuff'), 'c3')
}
compare(users)
}
/**
* @param {t.TestCase} tc
*/
export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
const { testConnector, users, map0, map1, map2, map3 } = init(tc, { users: 4 })
map0.setAttr('stuff', 'c0')
map1.setAttr('stuff', 'c1')
map1.setAttr('stuff', 'c2')
map2.setAttr('stuff', 'c3')
testConnector.flushAllMessages()
map0.setAttr('stuff', 'deleteme')
map1.setAttr('stuff', 'c1')
map2.setAttr('stuff', 'c2')
map3.setAttr('stuff', 'c3')
map3.deleteAttr('stuff')
testConnector.flushAllMessages()
for (const user of users) {
const u = user.get('map')
t.assert(u.getAttr('stuff') === undefined)
}
compare(users)
}
/**
* @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.setAttr('stuff', 4)
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in contents
map0.setAttr('stuff', new Y.Node())
compareEvent(event, {
target: map0,
keysChanged: new Set(['stuff'])
})
// update, oldValue is in opContents
map0.setAttr('stuff', 5)
// delete
map0.deleteAttr('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.setAttr('stuff', 4)
map0.setAttr('otherstuff', new Y.Node())
// clear
map0.clearAttrs()
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 {delta.Delta<any>?}
*/
let changes = delta.create()
map0.observe(e => {
changes = e.delta
})
map0.setAttr('a', 1)
let keyChange = changes.attrs.a
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 1)
map0.setAttr('a', 2)
keyChange = changes.attrs.a
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
users[0].transact(() => {
map0.setAttr('a', 3)
map0.setAttr('a', 4)
})
keyChange = changes.attrs.a
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 4)
users[0].transact(() => {
map0.setAttr('b', 1)
map0.setAttr('b', 2)
})
keyChange = changes.attrs.b
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
users[0].transact(() => {
map0.setAttr('c', 1)
map0.deleteAttr('c')
})
t.assert(changes !== null && object.isEmpty(changes.attrs))
users[0].transact(() => {
map0.setAttr('d', 1)
map0.setAttr('d', 2)
})
keyChange = changes.attrs.d
t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2)
compare(users)
}
/**
* @param {t.TestCase} _tc
*/
export const testYmapEventExceptionsShouldCompleteTransaction = _tc => {
const doc = new Y.Doc()
const map = doc.get('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.setAttr('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.setAttr('z', '3')
})
t.assert(updateCalled)
t.assert(throwingObserverCalled)
t.assert(throwingDeepObserverCalled)
t.assert(map.getAttr('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.setAttr('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.setAttr('stuff', 2)
testConnector.flushAllMessages()
t.compare(event.value, event.target.get(event.name))
compare(users)
}
/**
* @param {t.TestCase} _tc
*/
export const testAttributedContent = _tc => {
const ydoc = new Y.Doc({ gc: false })
const ymap = ydoc.get()
let renderer = /** @type {AbstractRenderer?} */ (null)
ydoc.on('afterTransaction', tr => {
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
renderer = new AttributionsRenderer(Y.createContentMap(createIdMapFromIdSet(tr.insertSet, []), createIdMapFromIdSet(tr.deleteSet, [])))
})
t.group('initial value', () => {
ymap.setAttr('test', 42)
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 42, attribution: { insert: [] } }) }
const attributedContent = ymap.toDelta({ renderer })
console.log(attributedContent.toJSON())
t.compare(expectedContent, attributedContent.toJSON().attrs)
})
t.group('overwrite value', () => {
ymap.setAttr('test', 'fourtytwo')
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { insert: [] } }) }
const attributedContent = ymap.toDelta({ renderer })
console.log(attributedContent)
t.compare(expectedContent, attributedContent.toJSON().attrs)
})
t.group('delete value', () => {
ymap.deleteAttr('test')
// Snapshot-mode `toDelta(renderer)` (no `itemsToRender` opt) must not emit
// `DeleteAttrOp`. An attribute deleted under attribution is still
// observable in the rendered state with its prior value and a `delete`
// attribution marker - symmetric with how soft-deleted content children
// surface as `InsertOp` with `{ delete: [] }` rather than `DeleteOp`.
const expectedContent = { test: delta.$deltaMapChangeJson.expect({ type: 'insert', value: 'fourtytwo', attribution: { delete: [] } }) }
const attributedContent = ymap.toDelta({ renderer })
console.log(attributedContent.toJSON())
t.compare(expectedContent, attributedContent.toJSON().attrs)
})
}
/**
* @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.get('map').setAttr(key, value)
},
function setType (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
const type = new Y.Node()
user.get('map').setAttr(key, type)
if (prng.bool(gen)) {
type.insert(0, [1, 2, 3, 4])
} else {
type.setAttr('deepkey', 'deepvalue')
}
},
function _delete (user, gen) {
const key = prng.oneOf(gen, ['one', 'two'])
user.get('map').deleteAttr(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)
}

2385
tests/y-text.tests.js Normal file

File diff suppressed because it is too large Load Diff

473
tests/y-xml.tests.js Normal file
View File

@@ -0,0 +1,473 @@
import * as Y from '../src/index.js'
import { init, compare } from './testHelper.js'
import * as t from 'lib0/testing'
import * as delta from 'lib0/delta'
export const testCustomTypings = () => {
const ydoc = new Y.Doc()
const ymap = ydoc.get()
/**
* @type {Y.Node<{ attrs: { num: number, str: string, [k:string]: number|string } }>}
*/
const yxml = ymap.setAttr('yxml', new Y.Node('test'))
/**
* @type {number|undefined}
*/
const num = yxml.getAttr('num')
/**
* @type {string|undefined}
*/
const str = yxml.getAttr('str')
/**
* @type {object|number|string|undefined}
*/
const dtrn = yxml.getAttr('dtrn')
const attrs = yxml.getAttrs()
/**
* @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.setAttr('height', '10')
t.assert(xml0.getAttr('height') === '10', 'Simple set+get works')
testConnector.flushAllMessages()
t.assert(xml1.getAttr('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.setAttr('height', '10')
t.assert(xml0.hasAttr('height'), 'Simple set+has works')
testConnector.flushAllMessages()
t.assert(xml1.hasAttr('height'), 'Simple set+has works (remote)')
xml0.deleteAttr('height')
t.assert(!xml0.hasAttr('height'), 'Simple set+remove+has works')
testConnector.flushAllMessages()
t.assert(!xml1.hasAttr('height'), 'Simple set+remove+has works (remote)')
compare(users)
}
/**
* @param {t.TestCase} _tc
*/
export const testYtextAttributes = _tc => {
const ydoc = new Y.Doc()
const ytext = ydoc.get('')
ytext.observe(event => {
t.assert(event.delta.attrs.test?.type === 'insert')
})
ytext.setAttr('test', 42)
t.compare(ytext.getAttr('test'), 42)
t.compare(ytext.getAttrs(), { test: 42 })
}
/**
* @param {t.TestCase} _tc
*/
export const testSiblings = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.get()
const first = new Y.Node()
const second = new Y.Node('p')
yxml.insert(0, [first, second])
t.assert(first.parent === /** @type {Y.Node<any>} */ (yxml))
t.assert(yxml.parent === null)
}
/**
* @param {t.TestCase} _tc
*/
export const testClone = _tc => {
const ydoc = new Y.Doc()
const yxml = ydoc.get()
const first = new Y.Node('text')
const second = new Y.Node('p')
const third = new Y.Node('p')
yxml.push([first, second, third])
t.compareArrays(yxml.toArray(), [first, second, third])
const cloneYxml = yxml.clone()
ydoc.get('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 = ydoc.get()
const q = delta.create()
.insert('A', { em: {}, strong: {} })
.insert('B', { em: {} })
.insert('C', { em: {}, strong: {} })
yxml.applyDelta(q)
t.compare(yxml.toDelta(), q)
}
/**
* @param {t.TestCase} _tc
*/
export const testElement = _tc => {
const ydoc = new Y.Doc()
const yxmlel = ydoc.get()
const text1 = new Y.Node('text1')
const text2 = new Y.Node('text2')
yxmlel.insert(0, [text1, text2])
t.compareArrays(yxmlel.toArray(), [text1, text2])
}
/**
* @param {t.TestCase} _tc
*/
export const testFragmentAttributedContent = _tc => {
const ydoc = new Y.Doc({ gc: false })
const yfragment = new Y.Node()
const elem1 = Y.Node.from(delta.create().insert('hello'))
const elem2 = new Y.Node()
const elem3 = Y.Node.from(delta.create().insert('world'))
yfragment.insert(0, [elem1, elem2])
ydoc.get().insert(0, [yfragment])
let renderer = /** @type {AbstractRenderer?} */ (null)
ydoc.on('afterTransaction', tr => {
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
})
t.group('insert / delete', () => {
ydoc.transact(() => {
yfragment.delete(0, 1)
yfragment.insert(1, [elem3])
})
const expectedContent = delta.create().insert([elem1], null, { delete: [] }).insert([elem2]).insert([elem3], null, { insert: [] })
const attributedContent = yfragment.toDelta({ renderer })
console.log(attributedContent.toJSON())
t.assert(attributedContent.equals(expectedContent))
t.compare(elem1.toDelta({ renderer }).toJSON(), delta.create().insert('hello', null, { delete: [] }).toJSON())
})
}
/**
* @param {t.TestCase} _tc
*/
export const testElementAttributedContent = _tc => {
const ydoc = new Y.Doc({ gc: false })
const yelement = ydoc.get('p')
const elem1 = delta.create().insert('hello').done()
const elem2 = delta.create('span').done()
const elem3 = delta.create().insert('world').done()
yelement.insert(0, [elem1, elem2])
let renderer = /** @type {AbstractRenderer?} */ (null)
ydoc.on('afterTransaction', tr => {
// renderer = new AttributionsRenderer(createIdMapFromIdSet(tr.insertSet, [new Y.Attribution('insertAt', 42), new Y.Attribution('insert', 'kevin')]), createIdMapFromIdSet(tr.deleteSet, [new Y.Attribution('delete', 'kevin')]))
renderer = new Y.AttributionsRenderer(Y.createContentMap(Y.createIdMapFromIdSet(tr.insertSet, []), Y.createIdMapFromIdSet(tr.deleteSet, [])))
})
t.group('insert / delete', () => {
ydoc.transact(() => {
yelement.delete(0, 1)
yelement.insert(1, [elem3])
yelement.setAttr('key', '42')
})
const expectedContent = delta.create()
.insert([delta.create().insert('hello', null, { delete: [] })], null, { delete: [] })
.insert([elem2])
.insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] })
.setAttr('key', '42', { insert: [] })
const attributedContent = yelement.toDeltaDeep({ renderer })
console.log('retrieved content', attributedContent.toJSON())
t.assert(attributedContent.equals(expectedContent))
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
})
}
/**
* @param {t.TestCase} _tc
*/
export const testElementAttributedContentViaDiffer = _tc => {
const ydocV1 = new Y.Doc()
ydocV1.get('p').insert(0, [delta.create().insert('hello'), delta.create('span')])
const ydoc = new Y.Doc()
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
const yelement = ydoc.get('p')
const elem2 = yelement.get(1) // new Y.XmlElement('span')
const elem3 = Y.Node.from(delta.create().insert('world'))
ydoc.transact(() => {
yelement.delete(0, 1)
yelement.insert(1, [elem3])
yelement.setAttr('key', '42')
})
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
const expectedContent = delta.create().insert([delta.create().insert('hello')], null, { delete: [] }).insert([elem2.toDeltaDeep()]).insert([delta.create().insert('world', null, { insert: [] })], null, { insert: [] }).setAttr('key', '42', { insert: [] })
const attributedContent = yelement.toDeltaDeep({ renderer })
console.log('children', attributedContent.toJSON().children)
console.log('attributes', attributedContent.toJSON().attrs)
t.compare(attributedContent.toJSON(), expectedContent.toJSON())
t.assert(attributedContent.equals(expectedContent))
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
t.group('test getContentDeep', () => {
const expectedContent = delta.create()
.insert(
[delta.create().insert('hello')],
null,
{ delete: [] }
)
.insert([delta.create('span')])
.insert([
delta.create().insert('world', null, { insert: [] })
], null, { insert: [] })
.setAttr('key', '42', { insert: [] })
const attributedContent = yelement.toDeltaDeep({ renderer })
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
console.log('attributes', attributedContent.toJSON().attrs)
t.assert(attributedContent.equals(expectedContent))
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
t.assert(attributedContent.name === null)
})
ydoc.transact(() => {
elem3.insert(0, 'big')
})
t.group('test getContentDeep after some more updates', () => {
t.info('expecting DiffRenderer to auto update itself')
const expectedContent = delta.create()
.insert(
[delta.create().insert('hello')],
null,
{ delete: [] }
)
.insert([delta.create('span')])
.insert([
delta.create().insert('bigworld', null, { insert: [] })
], null, { insert: [] })
.setAttr('key', '42', { insert: [] })
const attributedContent = yelement.toDeltaDeep({ renderer })
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
console.log('attributes', attributedContent.toJSON().attrs)
t.assert(attributedContent.equals(expectedContent))
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42', attribution: { insert: [] } } })
t.assert(attributedContent.name === null)
})
Y.applyUpdate(ydocV1, Y.encodeStateAsUpdate(ydoc))
t.group('test getContentDeep both docs synced', () => {
t.info('expecting DiffRenderer to auto update itself')
const expectedContent = delta.create().insert([delta.create('span')]).insert([
delta.create().insert('bigworld')
]).setAttr('key', '42')
const attributedContent = yelement.toDeltaDeep({ renderer })
console.log('children', JSON.stringify(attributedContent.toJSON().children, null, 2))
console.log('cs expec', JSON.stringify(expectedContent.toJSON(), null, 2))
console.log('attributes', attributedContent.toJSON().attrs)
t.assert(attributedContent.equals(expectedContent))
t.compare(attributedContent.toJSON().attrs, { key: { type: 'insert', value: '42' } })
t.assert(attributedContent.name === null)
})
}
/**
* @param {t.TestCase} _tc
*/
export const testRendererSimpleExample = _tc => {
const ydoc = new Y.Doc()
ydoc.clientID = 0
// create some initial content
ydoc.get().insert(0, [delta.create().insert('hello world')])
const ydocFork = new Y.Doc()
ydocFork.clientID = 1
Y.applyUpdate(ydocFork, Y.encodeStateAsUpdate(ydoc))
// modify the fork
// append a span element
ydocFork.get().insert(1, [new Y.Node('span')])
const ytext = ydocFork.get().get(0)
// make "hello" italic
ytext.format(0, 5, { italic: true })
ytext.insert(11, 'deleteme')
ytext.delete(11, 8)
ytext.insert(11, '!')
// highlight the changes
console.log(JSON.stringify(ydocFork.get().toDeltaDeep({ renderer: Y.createDiffRenderer(ydoc, ydocFork) }), null, 2))
/* =>
{
"children": {
"ops": [
{
"insert": [
{
"ops": [
{
"insert": "hello",
"attributes": {
"italic": true
},
"attribution": {
"attributes": {
"italic": [] -- the attribute "italic" was changed
}
}
},
{
"insert": " world" -- "world" remains unchanged
},
{
"insert": "!",
"attribution": {
"insert": [] -- "!" was inserted
}
}
]
}
]
},
{
"insert": [
{
"nodeName": "span",
"children": {
"ops": []
},
"attributes": {}
}
],
"attribution": {
"insert": [] -- A <span/> tag was inserted
}
}
]
}
}
*/
}
/**
* Walk a delta tree and collect every op whose constructor matches one of the
* forbidden names. Returns a list of `{ path, opType, op }` for reporting.
*
* @param {any} d
* @param {Array<string>} forbidden - constructor names that should never appear
* @param {string} [path]
* @param {Array<{path:string, opType:string, op:any}>} [acc]
*/
const collectForbiddenOps = (d, forbidden, path = '$', acc = []) => {
if (d == null || typeof d !== 'object') return acc
if (d.attrs != null) {
for (const attrOp of d.attrs) {
const name = attrOp?.constructor?.name
if (name && forbidden.includes(name)) {
acc.push({ path: `${path}.attrs[${attrOp.key}]`, opType: name, op: attrOp })
}
}
}
if (d.children?.start != null) {
let op = d.children.start
let i = 0
while (op != null) {
const name = op.constructor?.name
if (name && forbidden.includes(name)) {
acc.push({ path: `${path}.children[${i}]`, opType: name, op })
}
if (op.insert && Array.isArray(op.insert)) {
op.insert.forEach((/** @type {any} */ nested, /** @type {number} */ j) => collectForbiddenOps(nested, forbidden, `${path}.children[${i}].insert[${j}]`, acc))
}
if (op.value != null) {
collectForbiddenOps(op.value, forbidden, `${path}.children[${i}].value`, acc)
}
op = op.next
i++
}
}
return acc
}
/**
* Reproduces the y-prosemirror issue #247 contract violation at the @y/y
* level: `ynode.toDeltaDeep({ renderer })` is supposed to surface soft-deleted content
* as positive ops (`SetAttrOp` / `InsertOp`) carrying attribution metadata,
* never as `DeleteAttrOp` / `DeleteOp`. Today, when a parent YXmlElement is
* itself soft-deleted under attribution, `nodeMapGetDelta` (ynode.js:1928)
* and the children traversal in `toDelta` emit raw delete ops for the
* cascaded child setAttr / content items - which downstream consumers
* (lib0/delta `diff`, y-prosemirror's PM mapper) cannot handle.
*
* Expected after fix: walking the delta returned by `parent.toDeltaDeep({ renderer })`
* finds zero `DeleteAttrOp` entries in any `attrs` map and zero `DeleteOp`
* entries in any `children` list, at every nesting level.
*
* @param {t.TestCase} _tc
*/
export const testToDeltaDeepEmitsNoDeleteOpsForSoftDeletedParent = _tc => {
// Base doc: a fragment containing one YXmlElement that has an `id` attr
// and one nested text child.
const ydocV1 = new Y.Doc({ gc: false })
const parentV1 = ydocV1.get('frag')
const childV1 = new Y.Node('item')
childV1.setAttr('id', 'C')
childV1.insert(0, [delta.create().insert('hello')])
parentV1.insert(0, [childV1])
// Forked doc: copy V1, then suggestion-delete the YXmlElement child by
// removing it from the fragment. Under the diff AM this surfaces as a
// soft-deleted child whose own `id` setAttr Item is cascade-tombstoned.
const ydoc = new Y.Doc({ gc: false })
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
const parent = ydoc.get('frag')
ydoc.transact(() => {
parent.delete(0, 1)
})
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
const rendered = parent.toDeltaDeep({ renderer })
// The cascade should surface as positive ops with attribution, not as
// delete ops. Find any DeleteAttrOp / DeleteOp anywhere in the tree.
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
if (offenders.length > 0) {
console.log('Forbidden delete ops in toDeltaDeep output:')
for (const o of offenders) {
console.log(` ${o.opType} at ${o.path}`)
}
console.log('Full delta:', JSON.stringify(rendered.toJSON(), null, 2))
}
t.assert(
offenders.length === 0,
`toDeltaDeep(renderer) emitted ${offenders.length} forbidden delete op(s) for a soft-deleted parent (issue #247 / y-prosemirror)`
)
}
/**
* Companion to the cascade case above: an explicit `deleteAttr` under a diff
* AM must also surface as a positive `SetAttrOp` carrying the prior value and
* `{ delete: [] }` attribution, never as a `DeleteAttrOp`. The same contract
* applies whether the attribute deletion came from a parent-cascade or from
* a direct `ynode.deleteAttr(key)` call.
*
* @param {t.TestCase} _tc
*/
export const testToDeltaDeepRendersExplicitDeleteAttrAsSetAttrWithAttribution = _tc => {
const ydocV1 = new Y.Doc({ gc: false })
ydocV1.get('p').setAttr('id', 'C')
const ydoc = new Y.Doc({ gc: false })
Y.applyUpdate(ydoc, Y.encodeStateAsUpdate(ydocV1))
ydoc.transact(() => {
ydoc.get('p').deleteAttr('id')
})
const renderer = Y.createDiffRenderer(ydocV1, ydoc)
const rendered = ydoc.get('p').toDeltaDeep({ renderer })
const offenders = collectForbiddenOps(rendered, ['DeleteAttrOp', 'DeleteOp'])
t.assert(offenders.length === 0, 'no DeleteAttrOp / DeleteOp from explicit deleteAttr under diff AM')
t.compare(
rendered.toJSON().attrs,
{ id: { type: 'insert', value: 'C', attribution: { delete: [] } } },
'explicit deleteAttr surfaces as SetAttrOp with prior value and delete attribution'
)
}

25
tsconfig.json Normal file
View File

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