commit 1ff79b5b83f629609dbd020460470d544d3c2140 Author: upstream-mirror Date: Tue Sep 15 17:21:43 2026 +0500 snapshot of https://github.com/yjs/yjs.git @ 96c96e1fcb1ef6ce866d5264b3f97f7f77b11f64 (2026-09-15) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..233ab0d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +github: dmonad +open_collective: y-collective diff --git a/.github/INCIDENT_RESPONSE.md b/.github/INCIDENT_RESPONSE.md new file mode 100644 index 0000000..776c133 --- /dev/null +++ b/.github/INCIDENT_RESPONSE.md @@ -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. diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 0000000..9e7ea1e --- /dev/null +++ b/.github/workflows/node.js.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..d59be6f --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..b209c80 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c844d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.vscode +docs +next.md diff --git a/.jsdoc.json b/.jsdoc.json new file mode 100644 index 0000000..2d92c47 --- /dev/null +++ b/.jsdoc.json @@ -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" + } +} diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..8e3d5f0 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "default": true, + "no-inline-html": false +} diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 0000000..bc204ec --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://yjs.dev diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..216588a --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e63cce9 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/INTERNALS.md b/INTERNALS.md new file mode 100644 index 0000000..79bd7f2 --- /dev/null +++ b/INTERNALS.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f55e8b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2023 + - Kevin Jahns . + - 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f02ae7 --- /dev/null +++ b/README.md @@ -0,0 +1,1445 @@ + +# ![Yjs](https://yjs.dev/images/logo/yjs-120x120.png) + +> A CRDT framework with a powerful abstraction of shared data + +Yjs is a [CRDT implementation](#yjs-crdt-algorithm) that exposes its internal +data structure as *shared types*. Shared types are common data types like `Map` +or `Array` with superpowers: changes are automatically distributed to other +peers and merged without merge conflicts. + +Yjs is **network agnostic** (p2p!), supports many existing **rich text +editors**, **offline editing**, **version snapshots**, **undo/redo** and +**shared cursors**. It scales well with an unlimited number of users and is well +suited for even large documents. + +* Demos: [https://github.com/yjs/yjs-demos](https://github.com/yjs/yjs-demos) +* Discuss: [https://discuss.yjs.dev](https://discuss.yjs.dev) +* Chat: [Gitter](https://gitter.im/Yjs/community) | [Discord](https://discord.gg/T3nqMT6qbM) +* Benchmark Yjs vs. Automerge: + [https://github.com/dmonad/crdt-benchmarks](https://github.com/dmonad/crdt-benchmarks) +* Podcast [**"Yjs Deep Dive into real time collaborative editing solutions":**](https://www.tag1consulting.com/blog/deep-dive-real-time-collaborative-editing-solutions-tagteamtalk-001-0) +* Podcast [**"Google Docs-style editing in Gutenberg with the YJS framework":**](https://publishpress.com/blog/yjs/) + +:construction_worker_woman: If you are looking for professional support, please +consider supporting this project via a "support contract" on +[GitHub Sponsors](https://github.com/sponsors/dmonad). I will attend your issues +quicker and we can discuss questions and problems in regular video conferences. +Otherwise you can find help on our community [discussion board](https://discuss.yjs.dev). + +## Sponsorship + +Please contribute to the project financially - especially if your company relies +on Yjs. [![Become a Sponsor](https://img.shields.io/static/v1?label=Become%20a%20Sponsor&message=%E2%9D%A4&logo=GitHub&style=flat&color=d42f2d)](https://github.com/sponsors/dmonad) + +## Professional Support + +* [Support Contract with the Maintainer](https://github.com/sponsors/dmonad) - +By contributing financially to the open-source Yjs project, you can receive +professional support directly from the author. This includes the opportunity for +weekly video calls to discuss your specific challenges. +* [Synergy Codes](https://synergycodes.com/yjs-services/) - Specializing in +consulting and developing real-time collaborative editing solutions for visual +apps, Synergy Codes focuses on interactive diagrams, complex graphs, charts, and +various data visualization types. Their expertise empowers developers to build +engaging and interactive visual experiences leveraging the power of Yjs. See +their work in action at [Visual Collaboration +Showcase](https://yjs-diagram.synergy.codes/). + +## Who is using Yjs + +* [AFFiNE](https://affine.pro/) A local-first, privacy-first, open source + knowledge base. :star2: +* [Cargo](https://cargo.site/) Site builder for designers and artists :star2: +* [Gitbook](https://gitbook.com) Knowledge management for technical teams :star2: +* [Evernote](https://evernote.com) Note-taking app :star2: +* [Lessonspace](https://thelessonspace.com) Enterprise platform for virtual + classrooms and online training :star2: +* [Ellipsus](ellipsus.com) - Collaborative writing app for storytelling etc. + Supports versioning, change attribution, and "blame". A solution for the whole + publishing process (also selling) :star: +* [Dynaboard](https://dynaboard.com/) Build web apps collaboratively. :star: +* [Relm](https://www.relm.us/) A collaborative gameworld for teamwork and + community. :star: +* [Huly](https://huly.io/) - Open Source All-in-One Project Management Platform +* [Room.sh](https://room.sh/) A meeting application with integrated + collaborative drawing, editing, and coding tools. :star: +* [Nimbus Note](https://nimbusweb.me/note.php) A note-taking app designed by + Nimbus Web. :star: +* [Pluxbox RadioManager](https://getradiomanager.com/) A web-based app to + collaboratively organize radio broadcasts. :star: +* [modyfi](https://www.modyfi.com) - Modyfi is the design platform built for + multidisciplinary designers. Design, generate, animate, and more — without + switching between apps. :star: +* [Sana](https://sanalabs.com/) A learning platform with collaborative text + editing powered by Yjs. +* [Serenity Notes](https://www.serenity.re/en/notes) End-to-end encrypted + collaborative notes app. +* [PRSM](https://prsm.uk/) Collaborative mind-mapping and system visualisation. + *[(source)](https://github.com/micrology/prsm)* +* [Alldone](https://alldone.app/) A next-gen project management and + collaboration platform. +* [Living Spec](https://livingspec.com/) A modern way for product teams to collaborate. +* [Slidebeamer](https://slidebeamer.com/) Presentation app. +* [BlockSurvey](https://blocksurvey.io) End-to-end encryption for your forms/surveys. +* [Skiff](https://skiff.org/) Private, decentralized workspace. +* [JupyterLab](https://jupyter.org/) Collaborative computational Notebooks +* [JupyterCad](https://jupytercad.readthedocs.io/en/latest/) Extension to + JupyterLab that enables collaborative editing of 3d FreeCAD Models. +* [JupyterGIS](https://github.com/geojupyter/jupytergis) Collaborative GIS + (Geographic Information System) editor in Jupyter +* [Hyperquery](https://hyperquery.ai/) A collaborative data workspace for + sharing analyses, documentation, spreadsheets, and dashboards. +* [Nosgestesclimat](https://nosgestesclimat.fr/groupe) The french carbon + footprint calculator has a group P2P mode based on yjs +* [SupaKit](https://supakit.app) Spaces for every idea, task, or project. +* [LegendKeeper](https://legendkeeper.com) Collaborative campaign planner and + worldbuilding app for tabletop RPGs. +* [IllumiDesk](https://illumidesk.com/) Build courses and content with A.I. +* [btw](https://www.btw.so) Open-source Medium alternative +* [AWS SageMaker](https://aws.amazon.com/sagemaker/) Tools for building Machine + Learning Models +* [linear](https://linear.app) Streamline issues, projects, and product roadmaps. +* [Arkiter](https://www.arkiter.com/) - Live interview software +* [Appflowy](https://www.appflowy.io/) - They use Yrs +* [Multi.app](https://multi.app) - Multiplayer app sharing: Point, draw and edit + in shared apps as if they're on your computer. They are using Yrs. +* [AppMaster](https://appmaster.io) A No-Code platform for creating + production-ready applications with source code generation. +* [Synthesia](https://www.synthesia.io) - Collaborative Video Editor +* [thinkdeli](https://thinkdeli.com) - A fast and simple notes app powered by AI +* [ourboard](https://github.com/raimohanska/ourboard) - A collaborative whiteboard + application +* [Ellie.ai](https://ellie.ai) - Data Product Design and Collaboration +* [GoPeer](https://gopeer.org/) - Collaborative tutoring +* [screen.garden](https://screen.garden) - Collaborative backend for PKM apps. +* [NextCloud](https://nextcloud.com/) - Content Collaboration Platform +* [keystatic](https://github.com/Thinkmill/keystatic) - git-based CMS +* [QDAcity](https://qdacity.com) - Collaborative qualitative data analysis platform +* [Kanbert](https://kanbert.com) - Project management software +* [Eclipse Theia](https://github.com/eclipse-theia/theia) - A cloud & desktop + IDE that runs in the browser. +* [ScienHub](https://scienhub.com) - Collaborative LaTeX editor in the browser. +* [Open Collaboration Tools](https://www.open-collab.tools/) - Collaborative +editing for your IDE or custom editor +* [Typst](https://typst.app/) - Compose, edit, and automate technical documents +* [Kedyou](https://kedyou.com/) - Digital workspaces for tutoring +* [Lightpage](https://lightpage.com/) - Personal living notebook +* [reearth-flow](https://github.com/reearth/reearth-flow) - + Collaboratively calculate and convert various data +* [ProtonMail | Proton Docs](https://proton.me/drive/docs) - E2E encrypted + collaborative documents in Proton Drive. +* [Theneo](https://www.theneo.io/) - AI-powered API docs with live team collaboration. +* [ToolJet](https://tooljet.com/) - Build full-stack enterprise internal apps in + minutes. + +## Table of Contents + +* [Overview](#overview) + * [Bindings](#bindings) + * [Providers](#providers) + * [Tooling](#tooling) + * [Ports](#ports) +* [Getting Started](#getting-started) +* [API](#api) + * [Shared Types](#shared-types) + * [Y.Doc](#ydoc) + * [Document Updates](#document-updates) + * [Relative Positions](#relative-positions) + * [Y.UndoManager](#yundomanager) +* [Yjs CRDT Algorithm](#yjs-crdt-algorithm) +* [License and Author](#license-and-author) + +## Overview + +This repository contains a collection of shared types that can be observed for +changes and manipulated concurrently. Network functionality and two-way-bindings +are implemented in separate modules. + +### Bindings + +| Name | Cursors | Binding | Demo | +| --- | :-: | --- | --- | +| [ProseMirror](https://prosemirror.net/)                                                   | ✔ | [y-prosemirror](https://github.com/yjs/y-prosemirror) | [demo](https://demos.yjs.dev/prosemirror/prosemirror.html) | +| [Quill](https://quilljs.com/) | ✔ | [y-quill](https://github.com/yjs/y-quill) | [demo](https://demos.yjs.dev/quill/quill.html) | +| [CodeMirror](https://codemirror.net/) | ✔ | [y-codemirror](https://github.com/yjs/y-codemirror) | [demo](https://demos.yjs.dev/codemirror/codemirror.html) | +| [Monaco](https://microsoft.github.io/monaco-editor/) | ✔ | [y-monaco](https://github.com/yjs/y-monaco) | [demo](https://demos.yjs.dev/monaco/monaco.html) | +| [Ace](https://ace.c9.io/) | ✔ | [y-ace](https://github.com/bajrangCoder/y-ace) | | +| [Slate](https://github.com/ianstormtaylor/slate) | ✔ | [slate-yjs](https://github.com/bitphinix/slate-yjs) | [demo](https://bitphinix.github.io/slate-yjs-example) | +| [BlockSuite](https://github.com/toeverything/blocksuite) | ✔ | (native) | [demo](https://blocksuite-toeverything.vercel.app/?init) | +| [Lexical](https://lexical.dev/) | ✔ | (native) | [demo](https://lexical.dev/docs/collaboration/react#see-it-in-action) | +| [BlockNote](https://www.blocknotejs.org/docs/collaboration/real-time-collaboration) | ✔ | [y-prosemirror](https://github.com/yjs/y-prosemirror) | [demo](https://www.blocknotejs.org/docs/collaboration/real-time-collaboration) | +| [Tiptap](https://tiptap.dev/) | ✔ | [y-prosemirror](https://github.com/yjs/y-prosemirror) | [demo](https://template.tiptap.dev/preview/templates/simple) | +| [Milkdown](https://github.com/Milkdown/milkdown) | ✔ | [y-prosemirror](https://github.com/yjs/y-prosemirror) | [demo](https://milkdown.dev/playground) | +| [Superdoc](https://superdoc.dev/) | ✔ | (native) | [demo](https://superdoc.dev/) | +| [valtio](https://github.com/pmndrs/valtio) | | [valtio-yjs](https://github.com/dai-shi/valtio-yjs) | [demo](https://codesandbox.io/s/valtio-yjs-demo-ox3iy) | +| [immer](https://github.com/immerjs/immer) | | [immer-yjs](https://github.com/sep2/immer-yjs) | [demo](https://codesandbox.io/s/immer-yjs-demo-6e0znb) | +| [mutative](https://github.com/unadlib/mutative) | | [mutative-yjs](https://github.com/mutativejs/mutative-yjs) | [demo](https://codesandbox.io/p/sandbox/fjrwk3) | +| React | | [react-yjs](https://github.com/nikgraf/react-yjs) | [demo](https://react-yjs-example.vercel.app/) | +| React / Vue / Svelte / MobX | | [SyncedStore](https://syncedstore.org) | [demo](https://syncedstore.org/docs/react) | +| [mobx-keystone](https://mobx-keystone.js.org/) | | [mobx-keystone-yjs](https://github.com/xaviergonz/mobx-keystone/tree/master/packages/mobx-keystone-yjs) | [demo](https://mobx-keystone.js.org/examples/yjs-binding) | +| [PSPDFKit](https://www.nutrient.io/) | | [yjs-pspdfkit](https://github.com/hoangqwe159/yjs-pspdfkit) | [demo](https://github.com/hoangqwe159/yjs-pspdfkit) | +| [Rows n'Columns](https://www.rowsncolumns.app/) | ✔ | [@rowsncolumns/y-spreadsheet](https://docs.rowsncolumns.app/collaboration/yjs-collaboration) | | + +### Utilities + +Tools that extend the core functionality of Yjs. + +
+
y-utility
+
+Library with YMultiDocUndoManager (undo/redo across Yjs docs) and +YKeyValue (optimized key-value store). +
+
+yjs-orderedtree 🌳 +
+
+Class for ordered trees via Y.Map. Handles insert, +delete, and move operations for folder-like +hierarchies. +
+ +
+ +### Providers + +Setting up the communication between clients, managing awareness information, +and storing shared data for offline usage is quite a hassle. **Providers** +manage all that for you and are the perfect starting point for your +collaborative app. + +> This list of providers is incomplete. Please open PRs to add your providers to +> this list! + +#### Connection Providers + +
+
y-websocket
+
+A module that contains a simple websocket backend and a websocket client that +connects to that backend. y-redis, +y-sweet, ypy-websocket, yrs-warp and +Hocuspocus (see below) are alternative +backends to y-websocket. +
+
y-webrtc
+
+Propagates document updates peer-to-peer using WebRTC. The peers exchange +signaling data over signaling servers. Publicly available signaling servers +are available. Communication over the signaling servers can be encrypted by +providing a shared secret, keeping the connection information and the shared +document private. +
+
Hocuspocus 🌟
+
+A standalone extensible yjs server with sqlite persistence, webhooks, auth and more. +
+
Velt YJs ⭐
+
Velt +A managed Yjs backend with realtime WebSocket sync and persistent storage - no +server setup required. Includes offline support with automatic reconnection, +version history, end-to-end custom encryption. Additionally it also provides Yjs +webhook events, REST API to read and update Yjs documents, and a browser +DevTools extension. +
+
@liveblocks/yjs ⭐
+
+Liveblocks Yjs +provides a fully hosted WebSocket infrastructure and persisted data +store for Yjs documents. No configuration or maintenance is required. It +also features Yjs webhook events, REST API to read and update Yjs +documents, and a browser DevTools extension. +
+
teleportal
+
+Build your own Y.js sync server: any storage, any JS runtime, any transport. +
+
y-sweet
+
+A standalone yjs server with persistence to S3 or filesystem. They offer a +cloud service as well. +
+
@superviz/yjs
+
+ SuperViz Yjs Provider comes with a secure, scalable real-time infrastructure + for Yjs documents, fully compatible with a set of real-time + collaboration components offered by SuperViz. This solution ensures + synchronization, offline editing, and real-time updates, enabling + multiple users to collaborate effectively within shared workspaces. +
+
PartyKit
+
+Cloud service for building multiplayer apps. +
+ +
@pluv/crdt-yjs
+
+Use pluv.io as a +full-featured backend for Yjs. pluv.io can either be be used on its +fully-managed WebSocket infrastructure, or self-hosted on Cloudflare Workers +and Node.js runtimes. Offers a typesafe API with authentication, webhooks, +rooms, and more. +
y-libp2p
+
+Uses libp2p to propagate updates via +GossipSub. +Also includes a peer-sync mechanism to catch up on missed updates. +
+
y-dat
+
+[WIP] Write document updates efficiently to the dat network using +multifeed. Each client has +an append-only log of CRDT local updates (hypercore). Multifeed manages and sync +hypercores and y-dat listens to changes and applies them to the Yjs document. +
+
Matrix-CRDT
+
+Use Matrix as an off-the-shelf backend for +Yjs by using the MatrixProvider. +Use Matrix as transport and storage of Yjs updates, so you can focus building +your client app and Matrix can provide powerful features like Authentication, +Authorization, Federation, hosting (self-hosting or SaaS) and even End-to-End +Encryption (E2EE). +
+
yrb-actioncable
+
+An ActionCable companion for Yjs clients. There is a fitting +redis extension as well. +
+
y-atproto
+
+A Yjs provider that syncs documents over the +AT Protocol (the decentralized protocol +behind Bluesky). Documents are stored as ATProto records and real-time updates +are delivered via Jetstream. +
+
ypy-websocket
+
+Websocket backend, written in Python. +
+
Tinybase
+
+The reactive data store for local-first apps. They support multiple CRDTs and + different network technologies. +
+
y-webxdc
+
+Provider for sharing data in webxdc chat apps. +
+
secsync
+
+An architecture to relay end-to-end encrypted CRDTs over a central service. +
+
y-electric
+
+ Sync Yjs over ElectricSQL. +
+
yjs-cf-ws-provider
+
+ Cloudflare provider for Yjs based on durable objects. +
+
nostr-crdt
+
+ Sync Yjs over nostr. +
+
+ +#### Persistence Providers + +
+
y-indexeddb
+
+Efficiently persists document updates to the browsers indexeddb database. +The document is immediately available and only diffs need to be synced through the +network provider. +
+
y-mongodb-provider
+
+Adds persistent storage to a server with MongoDB. Can be used with the +y-websocket provider. +
+
y-fire
+
+A database and connection provider for Yjs based on Firestore. +
+
y-op-sqlite
+
+ Persist YJS updates in your React Native app using + op-sqlite + , the fastest SQLite library for React Native. +
+
y-postgresql
+
+ Provides persistent storage for a web server using PostgreSQL and + is easily compatible with y-websocket. +
+
k_yrs_go
+
+ Golang database server for YJS CRDT using Postgres + Redis +
+
y-op-sqlite
+
+ Yjs persistence provider for op-sqlite +
+
+ +### Tooling + +* [y-sweet debugger](https://y-sweet.cloud/advanced/debugger) +* [liveblocks devtools](https://liveblocks.io/devtools) +* [Yjs inspector](https://inspector.yjs.dev) +* [velt devtools](https://velt.dev/devtools) + +### Ports + +There are several Yjs-compatible ports to other programming languages. + +* [y-octo](https://github.com/toeverything/y-octo) - Rust implementation by +[AFFiNE](https://affine.pro) +* [y-crdt](https://github.com/y-crdt/y-crdt) - Rust implementation with multiple +language bindings to other languages + * [yrs](https://github.com/y-crdt/y-crdt/tree/main/yrs) - Rust interface + * [pycrdt](https://github.com/y-crdt/pycrdt) - Python binding + * [yrb](https://github.com/y-crdt/yrb) - Ruby binding + * [yr](https://github.com/y-crdt/yr) - R binding + * [ydotnet](https://github.com/y-crdt/ydotnet) - .NET/C# binding + * [yswift](https://github.com/y-crdt/yswift) - Swift binding + * [ykt](https://github.com/y-crdt/ykt) - Kotlin binding + * [yffi](https://github.com/y-crdt/y-crdt/tree/main/yffi) - C-FFI + * [ywasm](https://github.com/y-crdt/y-crdt/tree/main/ywasm) - WASM binding + * [y_ex](https://github.com/satoren/y_ex) - Elixir binding + * [ypy](https://github.com/y-crdt/ypy) - Python binding (deprecated, use pycrdt) +* [ycs](https://github.com/yjs/ycs) - .Net compatible C# implementation. +* [ygo](https://github.com/reearth/ygo) - Go implementation. +* [ygo (Deln0r/ygo)](https://github.com/Deln0r/ygo) - Go implementation. + +## Getting Started + +Install Yjs and a provider with your favorite package manager: + +```sh +npm i yjs y-websocket +``` + +Start the y-websocket server: + +```sh +PORT=1234 node ./node_modules/y-websocket/bin/server.cjs +``` + +### Example: Observe types + +```js +import * as Y from 'yjs'; + +const doc = new Y.Doc(); +const yarray = doc.getArray('my-array') +yarray.observe(event => { + console.log('yarray was modified') +}) +// every time a local or remote client modifies yarray, the observer is called +yarray.insert(0, ['val']) // => "yarray was modified" +``` + +### Example: Nest types + +Remember, shared types are just plain old data types. The only limitation is +that a shared type must exist only once in the shared document. + +```js +const ymap = doc.getMap('map') +const foodArray = new Y.Array() +foodArray.insert(0, ['apple', 'banana']) +ymap.set('food', foodArray) +ymap.get('food') === foodArray // => true +ymap.set('fruit', foodArray) // => Error! foodArray is already defined +``` + +Now you understand how types are defined on a shared document. Next you can jump +to the [demo repository](https://github.com/yjs/yjs-demos) or continue reading +the API docs. + +### Example: Using and combining providers + +Any of the Yjs providers can be combined with each other. So you can sync data +over different network technologies. + +In most cases you want to use a network provider (like y-websocket or y-webrtc) +in combination with a persistence provider (y-indexeddb in the browser). +Persistence allows you to load the document faster and to persist data that is +created while offline. + +For the sake of this demo we combine two different network providers with a +persistence provider. + +```js +import * as Y from 'yjs' +import { WebrtcProvider } from 'y-webrtc' +import { WebsocketProvider } from 'y-websocket' +import { IndexeddbPersistence } from 'y-indexeddb' + +const ydoc = new Y.Doc() + +// this allows you to instantly get the (cached) documents data +const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc) +indexeddbProvider.whenSynced.then(() => { + console.log('loaded data from indexed db') +}) + +// Sync clients with the y-webrtc provider. +const webrtcProvider = new WebrtcProvider('count-demo', ydoc) + +// Sync clients with the y-websocket provider +const websocketProvider = new WebsocketProvider( + 'wss://demos.yjs.dev', 'count-demo', ydoc +) + +// array of numbers which produce a sum +const yarray = ydoc.getArray('count') + +// observe changes of the sum +yarray.observe(event => { + // print updates when the data changes + console.log('new sum: ' + yarray.toArray().reduce((a,b) => a + b)) +}) + +// add 1 to the sum +yarray.push([1]) // => "new sum: 1" +``` + +## API + +```js +import * as Y from 'yjs' +``` + +### Shared Types + +
+ Y.Array +
+

+A shareable Array-like type that supports efficient insert/delete of elements +at any position. Internally it uses a linked list of Arrays that is split when +necessary. +

+
const yarray = new Y.Array()
+
+ +Y.Array.from(Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>): +Y.Array + +
An alternative factory function to create a Y.Array based on existing content.
+ parent:Y.AbstractType|null +
+ insert(index:number, content:Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type>) +
+Insert content at index. Note that content is an array of elements. +I.e. array.insert(0, [1]) splices the list and inserts 1 at +position 0. +
+ push(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>) +
+ unshift(Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type>) +
+ delete(index:number, length:number) +
+ get(index:number) +
+ slice(start:number, end:number):Array<Object|boolean|Array|string|number|null|Uint8Array|Y.Type> +
Retrieve a range of content
+ length:number +
+ + +forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type, + index:number, array: Y.Array)) + + +
+ map(function(T, number, YArray):M):Array<M> +
+ clone(): Y.Array +
+Clone all values into a fresh Y.Array instance. The returned type can be +included into the Yjs document. +
+ toArray():Array<object|boolean|Array|string|number|null|Uint8Array|Y.Type> +
Copies the content of this YArray to a new Array.
+ toJSON():Array<Object|boolean|Array|string|number|null> +
+Copies the content of this YArray to a new Array. It transforms all child types +to JSON using their toJSON method. +
+ [Symbol.Iterator] +
+ Returns an YArray Iterator that contains the values for each index in + the array. +
for (let value of yarray) { .. }
+
+ observe(function(YArrayEvent, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type is modified. In the case this type is modified in the event listener, +the event listener will be called again after the current event listener returns. +
+ unobserve(function(YArrayEvent, Transaction):void) +
+ Removes an observe event listener from this type. +
+ observeDeep(function(Array<YEvent>, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type or any of its children is modified. In the case this type is modified +in the event listener, the event listener will be called again after the current +event listener returns. The event listener receives all Events created by itself +or any of its children. +
+ unobserveDeep(function(Array<YEvent>, Transaction):void) +
+ Removes an observeDeep event listener from this type. +
+
+
+
+ Y.Map +
+

+ A shareable Map type. +

+
const ymap = new Y.Map()
+
+ parent:Y.AbstractType|null +
+ size: number +
Total number of key/value pairs.
+ get(key:string):object|boolean|string|number|null|Uint8Array|Y.Type +
+ set(key:string, value:object|boolean|string|number|null|Uint8Array|Y.Type) +
+ delete(key:string) +
+ has(key:string):boolean +
+ clear() +
Removes all elements from this YMap.
+ clone():Y.Map +
Clone this type into a fresh Yjs type.
+ toJSON():Object<string, Object|boolean|Array|string|number|null|Uint8Array> +
+Copies the [key,value] pairs of this YMap to a new Object.It +transforms all child types to JSON using their toJSON method. +
+ forEach(function(value:object|boolean|Array|string|number|null|Uint8Array|Y.Type, + key:string, map: Y.Map)) +
+ Execute the provided function once for every key-value pair. +
+ [Symbol.Iterator] +
+ Returns an Iterator of [key, value] pairs. +
for (let [key, value] of ymap) { .. }
+
+ entries() +
+ Returns an Iterator of [key, value] pairs. +
+ values() +
+ Returns an Iterator of all values. +
+ keys() +
+ Returns an Iterator of all keys. +
+ observe(function(YMapEvent, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type is modified. In the case this type is modified in the event listener, +the event listener will be called again after the current event listener returns. +
+ unobserve(function(YMapEvent, Transaction):void) +
+ Removes an observe event listener from this type. +
+ observeDeep(function(Array<YEvent>, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type or any of its children is modified. In the case this type is modified +in the event listener, the event listener will be called again after the current +event listener returns. The event listener receives all Events created by itself +or any of its children. +
+ unobserveDeep(function(Array<YEvent>, Transaction):void) +
+ Removes an observeDeep event listener from this type. +
+
+
+ +
+ Y.Text +
+

+A shareable type that is optimized for shared editing on text. It allows to +assign properties to ranges in the text. This makes it possible to implement +rich-text bindings to this type. +

+

+This type can also be transformed to a delta +(lib0/delta). Similarly the +events compute changes as deltas. +

+
const ytext = new Y.Text()
+
+ parent:Y.AbstractType|null +
+ insert(index:number, content:string, [formattingAttributes:Object<string,string>]) +
+ Insert a string at index and assign formatting attributes to it. +
ytext.insert(0, 'bold text', { bold: true })
+
+ delete(index:number, length:number) +
+ format(index:number, length:number, formattingAttributes:Object<string,string>) +
Assign formatting attributes to a range in the text
+ applyDelta(delta: Delta, [origin: any], [opts: {renderer: AbstractRenderer}]) +
+ Apply a delta (lib0/delta) + on this shared type. The optional origin is stored on the + transaction (transaction.origin) and forwarded verbatim + on the emitted 'delta' event, so listeners can recognize + — and skip — changes they produced themselves (see the lib0 + RDT spec). opts.renderer renders the content + with attributions; positions in the delta are then interpreted + relative to the attributed content. +
ytext.applyDelta(delta.create().retain(5).insert('!').done(), origin)
+
+ length:number +
+ toString():string +
Transforms this type, without formatting options, into a string.
+ toJSON():string +
See toString
+ toDelta([opts: { renderer: AbstractRenderer }]):Delta +
+Transforms this type to a delta (lib0/delta), +optionally rendering attributed content with opts.renderer. +
+ observe(function(YTextEvent, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type is modified. In the case this type is modified in the event listener, +the event listener will be called again after the current event listener returns. +
+ unobserve(function(YTextEvent, Transaction):void) +
+ Removes an observe event listener from this type. +
+ observeDeep(function(Array<YEvent>, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type or any of its children is modified. In the case this type is modified +in the event listener, the event listener will be called again after the current +event listener returns. The event listener receives all Events created by itself +or any of its children. +
+ unobserveDeep(function(Array<YEvent>, Transaction):void) +
+ Removes an observeDeep event listener from this type. +
+
+
+ +
+ Y.XmlFragment +
+

+ A container that holds an Array of Y.XmlElements. +

+
const yxml = new Y.XmlFragment()
+
+ parent:Y.AbstractType|null +
+ firstChild:Y.XmlElement|Y.XmlText|null +
+ insert(index:number, content:Array<Y.XmlElement|Y.XmlText>) +
+ delete(index:number, length:number) +
+ get(index:number) +
+ slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText> +
Retrieve a range of content
+ length:number +
+ clone():Y.XmlFragment +
Clone this type into a fresh Yjs type.
+ toArray():Array<Y.XmlElement|Y.XmlText> +
Copies the children to a new Array.
+ toString():string +
Get the XML serialization of all descendants.
+ toJSON():string +
See toString.
+ createTreeWalker(filter: function(AbstractType<any>):boolean):Iterable +
Create an Iterable that walks through the children.
+ observe(function(YXmlEvent, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type is modified. In the case this type is modified in the event listener, +the event listener will be called again after the current event listener returns. +
+ unobserve(function(YXmlEvent, Transaction):void) +
+ Removes an observe event listener from this type. +
+ observeDeep(function(Array<YEvent>, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type or any of its children is modified. In the case this type is modified +in the event listener, the event listener will be called again after the current +event listener returns. The event listener receives all Events created by itself +or any of its children. +
+ unobserveDeep(function(Array<YEvent>, Transaction):void) +
+ Removes an observeDeep event listener from this type. +
+
+
+ +
+ Y.XmlElement +
+

+A shareable type that represents an XML Element. It has a nodeName, +attributes, and a list of children. But it makes no effort to validate its +content and be actually XML compliant. +

+
const yxml = new Y.XmlElement()
+
+ parent:Y.AbstractType|null +
+ firstChild:Y.XmlElement|Y.XmlText|null +
+ nextSibling:Y.XmlElement|Y.XmlText|null +
+ prevSibling:Y.XmlElement|Y.XmlText|null +
+ insert(index:number, content:Array<Y.XmlElement|Y.XmlText>) +
+ delete(index:number, length:number) +
+ get(index:number) +
+ length:number +
+ setAttribute(attributeName:string, attributeValue:string) +
+ removeAttribute(attributeName:string) +
+ getAttribute(attributeName:string):string +
+ getAttributes():Object<string,string> +
+ get(i:number):Y.XmlElement|Y.XmlText +
Retrieve the i-th element.
+ slice(start:number, end:number):Array<Y.XmlElement|Y.XmlText> +
Retrieve a range of content
+ clone():Y.XmlElement +
Clone this type into a fresh Yjs type.
+ toArray():Array<Y.XmlElement|Y.XmlText> +
Copies the children to a new Array.
+ toString():string +
Get the XML serialization of all descendants.
+ toJSON():string +
See toString.
+ observe(function(YXmlEvent, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every +time this type is modified. In the case this type is modified in the event +listener, the event listener will be called again after the current event +listener returns. +
+ unobserve(function(YXmlEvent, Transaction):void) +
+ Removes an observe event listener from this type. +
+ observeDeep(function(Array<YEvent>, Transaction):void) +
+Adds an event listener to this type that will be called synchronously every time +this type or any of its children is modified. In the case this type is modified +in the event listener, the event listener will be called again after the current +event listener returns. The event listener receives all Events created by itself +or any of its children. +
+ unobserveDeep(function(Array<YEvent>, Transaction):void) +
+ Removes an observeDeep event listener from this type. +
+
+
+ +### Y.Doc + +```js +const doc = new Y.Doc() +``` + +
+ clientID +
A unique id that identifies this client. (readonly)
+ gc +
+Whether garbage collection is enabled on this doc instance. Set `doc.gc = false` +in order to disable gc and be able to restore old content. See https://github.com/yjs/yjs#yjs-crdt-algorithm +for more information about gc in Yjs. +
+ transact(function(Transaction):void [, origin:any]) +
+Every change on the shared document happens in a transaction. Observer calls and +the update event are called after each transaction. You should +bundle changes into a single transaction to reduce the amount of event +calls. I.e. doc.transact(() => { yarray.insert(..); ymap.set(..) }) +triggers a single change event.
You can specify an optional origin +parameter that is stored on transaction.origin and +on('update', (update, origin) => ..). +
+ toJSON():any +
+Deprecated: It is recommended to call toJSON directly on the shared types. +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(..)). +
+ get(string, Y.[TypeClass]):[Type] +
Define a shared type.
+ getArray(string):Y.Array +
Define a shared Y.Array type. Is equivalent to y.get(string, Y.Array).
+ getMap(string):Y.Map +
Define a shared Y.Map type. Is equivalent to y.get(string, Y.Map).
+ getText(string):Y.Text +
Define a shared Y.Text type. Is equivalent to y.get(string, Y.Text).
+ getXmlElement(string, string):Y.XmlElement +
Define a shared Y.XmlElement type. Is equivalent to y.get(string, Y.XmlElement).
+ getXmlFragment(string):Y.XmlFragment +
Define a shared Y.XmlFragment type. Is equivalent to y.get(string, Y.XmlFragment).
+ on(string, function) +
Register an event listener on the shared type
+ off(string, function) +
Unregister an event listener from the shared type
+
+ +#### Y.Doc Events + +
+ on('update', function(updateMessage:Uint8Array, origin:any, Y.Doc):void) +
+Listen to document updates. Document updates must be transmitted to all other +peers. You can apply document updates in any order and multiple times. Use `updateV2` +to receive V2 events. +
+ on('beforeTransaction', function(Y.Transaction, Y.Doc):void) +
Emitted before each transaction.
+ on('afterTransaction', function(Y.Transaction, Y.Doc):void) +
Emitted after each transaction.
+ on('beforeAllTransactions', function(Y.Doc):void) +
+Transactions can be nested (e.g. when an event within a transaction calls another +transaction). Emitted before the first transaction. +
+ on('afterAllTransactions', function(Y.Doc, Array<Y.Transaction>):void) +
Emitted after the last transaction is cleaned up.
+
+ +### Document Updates + +Changes on the shared document are encoded into *document updates*. Document +updates are *commutative* and *idempotent*. This means that they can be applied +in any order and multiple times. + +#### Example: Listen to update events and apply them on remote client + +```js +const doc1 = new Y.Doc() +const doc2 = new Y.Doc() + +doc1.on('update', update => { + Y.applyUpdate(doc2, update) +}) + +doc2.on('update', update => { + Y.applyUpdate(doc1, update) +}) + +// All changes are also applied to the other document +doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?']) +doc2.getArray('myarray').get(0) // => 'Hello doc2, you got this?' +``` + +Yjs internally maintains a [state vector](#state-vector) that denotes the next +expected clock from each client. In a different interpretation it holds the +number of structs created by each client. When two clients sync, you can either +exchange the complete document structure or only the differences by sending the +state vector to compute the differences. + +#### Example: Sync two clients by exchanging the complete document structure + +```js +const state1 = Y.encodeStateAsUpdate(ydoc1) +const state2 = Y.encodeStateAsUpdate(ydoc2) +Y.applyUpdate(ydoc1, state2) +Y.applyUpdate(ydoc2, state1) +``` + +#### Example: Sync two clients by computing the differences + +This example shows how to sync two clients with the minimal amount of exchanged +data by computing only the differences using the state vector of the remote +client. Syncing clients using the state vector requires another roundtrip, but +can save a lot of bandwidth. + +```js +const stateVector1 = Y.encodeStateVector(ydoc1) +const stateVector2 = Y.encodeStateVector(ydoc2) +const diff1 = Y.encodeStateAsUpdate(ydoc1, stateVector2) +const diff2 = Y.encodeStateAsUpdate(ydoc2, stateVector1) +Y.applyUpdate(ydoc1, diff2) +Y.applyUpdate(ydoc2, diff1) +``` + +#### Example: Syncing clients without loading the Y.Doc + +It is possible to sync clients and compute delta updates without loading the Yjs +document to memory. Yjs exposes an API to compute the differences directly on the +binary document updates. + +```js +// encode the current state as a binary buffer +let currentState1 = Y.encodeStateAsUpdate(ydoc1) +let currentState2 = Y.encodeStateAsUpdate(ydoc2) +// now we can continue syncing clients using state vectors without using the Y.Doc +ydoc1.destroy() +ydoc2.destroy() + +const stateVector1 = Y.encodeStateVectorFromUpdate(currentState1) +const stateVector2 = Y.encodeStateVectorFromUpdate(currentState2) +const diff1 = Y.diffUpdate(currentState1, stateVector2) +const diff2 = Y.diffUpdate(currentState2, stateVector1) + +// sync clients +currentState1 = Y.mergeUpdates([currentState1, diff2]) +currentState2 = Y.mergeUpdates([currentState2, diff1]) +``` + +#### Obfuscating Updates + +If one of your users runs into a weird bug (e.g. the rich-text editor throws +error messages), then you don't have to request the full document from your +user. Instead, they can obfuscate the document (i.e. replace the content with +meaningless generated content) before sending it to you. Note that someone might +still deduce the type of content by looking at the general structure of the +document. But this is much better than requesting the original document. + +Obfuscated updates contain all the CRDT-related data that is required for +merging. So it is safe to merge obfuscated updates. + +```javascript +const ydoc = new Y.Doc() +// perform some changes.. +ydoc.getText().insert(0, 'hello world') +const update = Y.encodeStateAsUpdate(ydoc) +// the below update contains scrambled data +const obfuscatedUpdate = Y.obfuscateUpdate(update) +const ydoc2 = new Y.Doc() +Y.applyUpdate(ydoc2, obfuscatedUpdate) +ydoc2.getText().toString() // => "00000000000" +``` + +#### Using V2 update format + +Yjs implements two update formats. By default you are using the V1 update format. +You can opt-in into the V2 update format which provides much better compression. +It is not yet used by all providers. However, you can already use it if +you are building your own provider. All below functions are available with the +suffix "V2". E.g. `Y.applyUpdate` ⇒ `Y.applyUpdateV2`. Also when listening to updates +you need to specifically need listen for V2 events e.g. `yDoc.on('updateV2', …)`. +We also support conversion functions between both formats: +`Y.convertUpdateFormatV1ToV2` & `Y.convertUpdateFormatV2ToV1`. + +#### Update API + +
+ Y.applyUpdate(Y.Doc, update:Uint8Array, [transactionOrigin:any]) +
+Apply a document update on the shared document. Optionally you can specify +transactionOrigin that will be stored on +transaction.origin +and ydoc.on('update', (update, origin) => ..). +
+ Y.encodeStateAsUpdate(Y.Doc, [encodedTargetStateVector:Uint8Array]):Uint8Array +
+Encode the document state as a single update message that can be applied on the +remote document. Optionally specify the target state vector to only write the +differences to the update message. +
+ Y.encodeStateVector(Y.Doc):Uint8Array +
Computes the state vector and encodes it into an Uint8Array.
+ Y.mergeUpdates(Array<Uint8Array>) +
+Merge several document updates into a single document update while removing +duplicate information. The merged document update is always smaller than +the separate updates because of the compressed encoding. +
+ Y.encodeStateVectorFromUpdate(Uint8Array): Uint8Array +
+Computes the state vector from a document update and encodes it into an Uint8Array. +
+ Y.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array +
+Encode the missing differences to another update message. This function works +similarly to Y.encodeStateAsUpdate(ydoc, stateVector) but works +on updates instead. +
+ convertUpdateFormatV1ToV2 +
+Convert V1 update format to the V2 update format. +
+ convertUpdateFormatV2ToV1 +
+Convert V2 update format to the V1 update format. +
+
+ +### Relative Positions + +When working with collaborative documents, we often need to work with positions. +Positions may represent cursor locations, selection ranges, or even assign a +comment to a range of text. Normal index-positions (expressed as integers) are +not convenient to use because the index-range is invalidated as soon as a remote +change manipulates the document. Relative positions give you a powerful API to +express positions. + +A relative position is fixated to an element in the shared document and is not +affected by remote changes. I.e. given the document `"a|c"`, the relative +position is attached to `c`. When a remote user modifies the document by +inserting a character before the cursor, the cursor will stay attached to the +character `c`. `insert(1, 'x')("a|c") = "ax|c"`. When the relative position is +set to the end of the document, it will stay attached to the end of the +document. + +#### Example: Transform to RelativePosition and back + +```js +const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2) +const pos = Y.createAbsolutePositionFromRelativePosition(relPos, doc) +pos.type === ytext // => true +pos.index === 2 // => true +``` + +#### Example: Send relative position to remote client (json) + +```js +const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2) +const encodedRelPos = JSON.stringify(relPos) +// send encodedRelPos to remote client.. +const parsedRelPos = JSON.parse(encodedRelPos) +const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc) +pos.type === remoteytext // => true +pos.index === 2 // => true +``` + +#### Example: Send relative position to remote client (Uint8Array) + +```js +const relPos = Y.createRelativePositionFromTypeIndex(ytext, 2) +const encodedRelPos = Y.encodeRelativePosition(relPos) +// send encodedRelPos to remote client.. +const parsedRelPos = Y.decodeRelativePosition(encodedRelPos) +const pos = Y.createAbsolutePositionFromRelativePosition(parsedRelPos, remoteDoc) +pos.type === remoteytext // => true +pos.index === 2 // => true +``` + +
+ +Y.createRelativePositionFromTypeIndex(type:Uint8Array|Y.Type, index: number +[, assoc=0]) + +
+Create a relative position fixated to the i-th element in any sequence-like +shared type (if assoc >= 0). By default, the position associates +with the character that comes after the specified index position. If +assoc < 0, then the relative position associates with the character +before the specified index position. +
+ +Y.createAbsolutePositionFromRelativePosition(RelativePosition, Y.Doc): +{ type: Y.AbstractType, index: number, assoc: number } | null + +
+Create an absolute position from a relative position. If the relative position +cannot be referenced, or the type is deleted, then the result is null. +
+ +Y.encodeRelativePosition(RelativePosition):Uint8Array + +
+Encode a relative position to an Uint8Array. Binary data is the preferred +encoding format for document updates. If you prefer JSON encoding, you can +simply JSON.stringify / JSON.parse the relative position instead. +
+ Y.decodeRelativePosition(Uint8Array):RelativePosition +
Decode a binary-encoded relative position to a RelativePosition object.
+
+ +### Y.UndoManager + +Yjs ships with an Undo/Redo manager for selective undo/redo of changes on a +Yjs type. The changes can be optionally scoped to transaction origins. + +```js +const ytext = doc.getText('text') +const undoManager = new Y.UndoManager(ytext) + +ytext.insert(0, 'abc') +undoManager.undo() +ytext.toString() // => '' +undoManager.redo() +ytext.toString() // => 'abc' +``` + +
+ constructor(scope:Y.AbstractType|Array<Y.AbstractType> + [, {captureTimeout:number,trackedOrigins:Set<any>,deleteFilter:function(item):boolean,ignoreRemoteAttributeChanges:boolean}]) +
Accepts either single type as scope or an array of types.
+ ignoreRemoteAttributeChanges:boolean +
+By default, the UndoManager will never overwrite remote changes. In some cases +this is not the expected behavior. When set to true, undo/redo may +overwrite remote attribute changes (e.g. on Y.Map keys or +Y.XmlElement / Y.XmlText attributes). Defaults to +false. (previously named ignoreRemoteMapChanges) +
+ undo() +
+ redo() +
+ stopCapturing() +
+ + +on('stack-item-added', { stackItem: { meta: Map<any,any> }, type: 'undo' +| 'redo' }) + + +
+Register an event that is called when a StackItem is added to the +undo- or the redo-stack. +
+ + +on('stack-item-updated', { stackItem: { meta: Map<any,any> }, type: 'undo' +| 'redo' }) + + +
+Register an event that is called when an existing StackItem is updated. +This happens when two changes happen within a "captureInterval". +
+ + +on('stack-item-popped', { stackItem: { meta: Map<any,any> }, type: 'undo' +| 'redo' }) + + +
+Register an event that is called when a StackItem is popped from +the undo- or the redo-stack. +
+ + +on('stack-cleared', { undoStackCleared: boolean, redoStackCleared: boolean }) + + +
+Register an event that is called when the undo- and/or the redo-stack is cleared. +
+
+ +#### Example: Stop Capturing + +UndoManager merges Undo-StackItems if they are created within time-gap +smaller than `options.captureTimeout`. Call `um.stopCapturing()` so that the next +StackItem won't be merged. + +```js +// without stopCapturing +ytext.insert(0, 'a') +ytext.insert(1, 'b') +undoManager.undo() +ytext.toString() // => '' (note that 'ab' was removed) +// with stopCapturing +ytext.insert(0, 'a') +undoManager.stopCapturing() +ytext.insert(0, 'b') +undoManager.undo() +ytext.toString() // => 'a' (note that only 'b' was removed) +``` + +#### Example: Specify tracked origins + +Every change on the shared document has an origin. If no origin was specified, +it defaults to `null`. By specifying `trackedOrigins` you can +selectively specify which changes should be tracked by `UndoManager`. The +UndoManager instance is always added to `trackedOrigins`. + +```js +class CustomBinding {} + +const ytext = doc.getText('text') +const undoManager = new Y.UndoManager(ytext, { + trackedOrigins: new Set([42, CustomBinding]) +}) + +ytext.insert(0, 'abc') +undoManager.undo() +ytext.toString() // => 'abc' (does not track because origin `null` and not part + // of `trackedTransactionOrigins`) +ytext.delete(0, 3) // revert change + +doc.transact(() => { + ytext.insert(0, 'abc') +}, 42) +undoManager.undo() +ytext.toString() // => '' (tracked because origin is an instance of `trackedTransactionorigins`) + +doc.transact(() => { + ytext.insert(0, 'abc') +}, 41) +undoManager.undo() +ytext.toString() // => 'abc' (not tracked because 41 is not an instance of + // `trackedTransactionorigins`) +ytext.delete(0, 3) // revert change + +doc.transact(() => { + ytext.insert(0, 'abc') +}, new CustomBinding()) +undoManager.undo() +ytext.toString() // => '' (tracked because origin is a `CustomBinding` and + // `CustomBinding` is in `trackedTransactionorigins`) +``` + +#### Example: Add additional information to the StackItems + +When undoing or redoing a previous action, it is often expected to restore +additional meta information like the cursor location or the view on the +document. You can assign meta-information to Undo-/Redo-StackItems. + +```js +const ytext = doc.getText('text') +const undoManager = new Y.UndoManager(ytext, { + trackedOrigins: new Set([42, CustomBinding]) +}) + +undoManager.on('stack-item-added', event => { + // save the current cursor location on the stack-item + event.stackItem.meta.set('cursor-location', getRelativeCursorLocation()) +}) + +undoManager.on('stack-item-popped', event => { + // restore the current cursor location on the stack-item + restoreCursorLocation(event.stackItem.meta.get('cursor-location')) +}) +``` + +## Yjs CRDT Algorithm + +*Conflict-free replicated data types* (CRDT) for collaborative editing are an +alternative approach to *operational transformation* (OT). A very simple +differentiation between the two approaches is that OT attempts to transform +index positions to ensure convergence (all clients end up with the same +content), while CRDTs use mathematical models that usually do not involve index +transformations, like linked lists. OT is currently the de-facto standard for +shared editing on text. OT approaches that support shared editing without a +central source of truth (a central server) require too much bookkeeping to be +viable in practice. CRDTs are better suited for distributed systems, provide +additional guarantees that the document can be synced with remote clients, and +do not require a central source of truth. + +Yjs implements a modified version of the algorithm described in [this +paper](https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types). +This [article](https://blog.kevinjahns.de/are-crdts-suitable-for-shared-editing/) +explains a simple optimization on the CRDT model and +gives more insight about the performance characteristics in Yjs. +More information about the specific implementation is available in +[INTERNALS.md](./INTERNALS.md) and in +[this walkthrough of the Yjs codebase](https://youtu.be/0l5XgnQ6rB4). + +CRDTs that are suitable for shared text editing suffer from the fact that they +only grow in size. There are CRDTs that do not grow in size, but they do not +have the characteristics that are beneficial for shared text editing (like +intention preservation). Yjs implements many improvements to the original +algorithm that diminish the trade-off that the document only grows in size. We +can't garbage collect deleted structs (tombstones) while ensuring a unique +order of the structs. But we can 1. merge preceding structs into a single +struct to reduce the amount of meta information, 2. we can delete content from +the struct if it is deleted, and 3. we can garbage collect tombstones if we +don't care about the order of the structs anymore (e.g. if the parent was +deleted). + +**Examples:** + +1. If a user inserts elements in sequence, the struct will be merged into a + single struct. E.g. `text.insert(0, 'a'), text.insert(1, 'b');` is + first represented as two structs (`[{id: {client, clock: 0}, content: 'a'}, + {id: {client, clock: 1}, content: 'b'}`) and then merged into a single + struct: `[{id: {client, clock: 0}, content: 'ab'}]`. +2. When a struct that contains content (e.g. `ItemString`) is deleted, the + struct will be replaced with an `ItemDeleted` that does not contain content + anymore. +3. When a type is deleted, all child elements are transformed to `GC` structs. A + `GC` struct only denotes the existence of a struct and that it is deleted. + `GC` structs can always be merged with other `GC` structs if the id's are + adjacent. + +Especially when working on structured content (e.g. shared editing on +ProseMirror), these improvements yield very good results when +[benchmarking](https://github.com/dmonad/crdt-benchmarks) random document edits. +In practice they show even better results, because users usually edit text in +sequence, resulting in structs that can easily be merged. The benchmarks show +that even in the worst case scenario that a user edits text from right to left, +Yjs achieves good performance even for huge documents. + +### State Vector + +Yjs has the ability to exchange only the differences when syncing two clients. +We use lamport timestamps to identify structs and to track in which order a +client created them. Each struct has an `struct.id = { client: number, clock: +number}` that uniquely identifies a struct. We define the next expected `clock` +by each client as the *state vector*. This data structure is similar to the +[version vectors](https://en.wikipedia.org/wiki/Version_vector) data structure. +But we use state vectors only to describe the state of the local document, so we +can compute the missing struct of the remote client. We do not use it to track +causality. + +### Formal Proof + +[lean-yjs](https://github.com/iasakura/lean-yjs) provides a formal verification +of the YATA CRDT algorithm that Yjs implements, using the Lean theorem prover to +mathematically prove correctness properties. While the CRDT algorithm itself is +correct (currently proven for preservation and commutativity), the project +reveals that [the pseudocode in the original YATA paper contains +errors](https://discuss.yjs.dev/t/lean-yjs-formally-proving-the-yjs-conflict-resolution-algorithms/3875/2). + +## License and Author + +Yjs and all related projects are [**MIT licensed**](./LICENSE). + +Yjs is based on my research as a student at the [RWTH +i5](http://dbis.rwth-aachen.de/). Now I am working on Yjs in my spare time. + +Fund this project by donating on [GitHub Sponsors](https://github.com/sponsors/dmonad) +or hiring [me](https://github.com/dmonad) as a contractor for your collaborative +app. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1a6c6bc --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md new file mode 100644 index 0000000..0455302 --- /dev/null +++ b/THREAT_MODEL.md @@ -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. diff --git a/attributing-content.md b/attributing-content.md new file mode 100644 index 0000000..3492f84 --- /dev/null +++ b/attributing-content.md @@ -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. diff --git a/funding.json b/funding.json new file mode 100644 index 0000000..676c306 --- /dev/null +++ b/funding.json @@ -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 + } +} diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..ce40709 --- /dev/null +++ b/global.d.ts @@ -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 = import('./src/utils/ids.js').IdMap +declare type AttrRanges = import('./src/utils/ids.js').AttrRanges +declare type AttrRange = import('./src/utils/ids.js').AttrRange +declare type ContentAttribute = import('./src/utils/ids.js').ContentAttribute +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 = import('./src/ynode.js').YNode +declare type YEvent = import('./src/utils/YEvent.js').YEvent +declare type EventHandler = import('./src/utils/EventHandler.js').EventHandler + +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 = import('lib0/delta').Delta + +// @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 = import('./src/utils/renderer-helpers.js').AttributedContent + +declare type Snapshot = import('./src/utils/Snapshot.js').Snapshot diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4113c9c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5935 @@ +{ + "name": "@y/y", + "version": "14.0.0-rc.26", + "lockfileVersion": 3, + "requires": true, + "dev": true, + "packages": { + "": { + "name": "@y/y", + "version": "14.0.0-rc.26", + "license": "MIT", + "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": { + "node": ">=22.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@y/protocols": { + "version": "1.0.6-rc.1", + "resolved": "https://registry.npmjs.org/@y/protocols/-/protocols-1.0.6-rc.1.tgz", + "integrity": "sha512-e/qs7hXcLk/SeNitxMXv2ymozyWFTULwbJEi7cAf/K/iXw9nGwGXHrR5TNluQ/bMwOX1cwuUT0hjEojkfH0gsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lib0": "^1.0.0-rc.1" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@y/y": "*" + } + }, + "node_modules/@y/y": { + "resolved": "", + "link": true + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/builtins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-5.1.0.tgz", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.0.0" + } + }, + "node_modules/builtins/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dpdm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/dpdm/-/dpdm-4.0.1.tgz", + "integrity": "sha512-9NutC+V6A/4GHgqsqRTMQNDlhipK3lyPWBJ8zIVT8lTncy71d1BzR7sbCO0Dv2zTZ4XSxvbEfMyd0NDDLU0+fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "fs-extra": "^11.3.3", + "glob": "^13.0.0", + "ora": "^9.1.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "yargs": "^18.0.0" + }, + "bin": { + "dpdm": "lib/bin/dpdm.js" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-17.1.0.tgz", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" + } + }, + "node_modules/eslint-config-standard-jsx": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/eslint-config-standard-jsx/-/eslint-config-standard-jsx-11.0.0.tgz", + "integrity": "sha512-+1EV/R0JxEK1L0NGolAr8Iktm3Rgotx3BKwgaX+eAuSX8D952LULKtjgZD3F+e6SvibONnhLwoTi9DPxN5LvvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peerDependencies": { + "eslint": "^8.8.0", + "eslint-plugin-react": "^7.28.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-4.1.0.tgz", + "integrity": "sha512-GILhQTnjYE2WorX5Jyi5i4dz5ALWxBIdQECVQavL6s7cI76IZTDWleTHkxz/QT3kvcs2QlGHvKLYsSlPOlPXnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-plugin-es/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-n": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.7.0.tgz", + "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtins": "^5.0.1", + "eslint-plugin-es": "^4.1.0", + "eslint-utils": "^3.0.0", + "ignore": "^5.1.1", + "is-core-module": "^2.11.0", + "minimatch": "^3.1.2", + "resolve": "^1.22.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-n/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-promise": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.6.0.tgz", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lib0": { + "version": "1.0.0-rc.29", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-1.0.0-rc.29.tgz", + "integrity": "sha512-es7LFk0jx/DqVB3soBV0PycEgHJVCsdU/GMbVHWVKlt0qgP6wapCS8xorcMjUqId9m7/0nZCr+LLXeA7vtRVZA==", + "license": "MIT", + "bin": { + "0ecdsa-generate-keypair": "src/bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "src/bin/gentesthtml.js", + "0serve": "src/bin/0serve.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/load-json-file": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-5.3.0.tgz", + "integrity": "sha512-cJGP40Jc/VXUsp8/OrnyKyTZ1y6v/dphm3bioS+RrKXjK2BB6wHUd6JptZEFDGgGahMT+InnZO5i1Ei9mpC8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.15", + "parse-json": "^4.0.0", + "pify": "^4.0.1", + "strip-bom": "^3.0.0", + "type-fest": "^0.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/load-json-file/node_modules/type-fest": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.3.1.tgz", + "integrity": "sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/markdown-it": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.1.tgz", + "integrity": "sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdownlint": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/markdownlint/-/markdownlint-0.41.1.tgz", + "integrity": "sha512-qHKeU2E1bdyNAT077go2FVTNXvYcktN5IHtF6XyeD1l0PClxzSp2tUApAV14ORI8DGX4H9bNKZEzelZp4qn8IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark": "4.0.2", + "micromark-core-commonmark": "2.0.3", + "micromark-extension-directive": "4.0.0", + "micromark-extension-gfm-autolink-literal": "2.1.0", + "micromark-extension-gfm-footnote": "2.1.0", + "micromark-extension-gfm-table": "2.1.1", + "micromark-extension-math": "3.1.0", + "micromark-util-types": "2.0.2", + "string-width": "8.2.1" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-cli": { + "version": "0.49.1", + "resolved": "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.49.1.tgz", + "integrity": "sha512-qpYqJbSYf3jv57bdnFmCaZ/Wlu6IYHp2b6SOKrKBJ7OnPrDHIKmx4NERWH49QH9viTI6yO6raVDDn5nrf60VQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "~15.0.0", + "deep-extend": "~0.6.0", + "ignore": "~7.0.6", + "js-yaml": "~5.2.1", + "jsonc-parser": "~3.3.1", + "jsonpointer": "~5.0.1", + "markdown-it": "~14.3.0", + "markdownlint": "~0.41.1", + "minimatch": "~10.2.5", + "run-con": "~1.3.3", + "smol-toml": "~1.7.0", + "tinyglobby": "~0.2.17" + }, + "bin": { + "markdownlint": "markdownlint.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/markdownlint-cli/node_modules/js-yaml": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-conf/-/pkg-conf-3.1.0.tgz", + "integrity": "sha512-m0OTbR/5VPNPqO1ph6Fqbj7Hv6QU7gR/tQW40ZqrL1rjgCU85W6C1bJn0BItuJqnR98PWzw7Z8hHeChD1WrgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^3.0.0", + "load-json-file": "^5.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-conf/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-conf/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/run-con": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/run-con/-/run-con-1.3.3.tgz", + "integrity": "sha512-Lb7OKM9aaykzyoNiHGhSVCjZsvbyy6qDMp2vDXL+MoCfz3GfNJtHYH7uYsU3QNMyInBk++xx+EZ8xZ8Sxs5fNQ==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~7.0.0", + "minimist": "^1.2.8", + "strip-json-comments": "~3.1.1" + }, + "bin": { + "run-con": "cli.js" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smol-toml": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.2.tgz", + "integrity": "sha512-pXFZ9B2WinEPzxWkMmlYE/oYx2BP+qLrE95wP8tCuK901uLSMGdCb6QSr82z+wnhXkG4+cO+OMLbZB2Cn+97zw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/standard": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.2.tgz", + "integrity": "sha512-WLm12WoXveKkvnPnPnaFUUHuOB2cUdAsJ4AiGHL2G0UNMrcRAWY2WriQaV8IQ3oRmYr0AWUbLNr94ekYFAHOrA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "eslint": "^8.41.0", + "eslint-config-standard": "17.1.0", + "eslint-config-standard-jsx": "^11.0.0", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-n": "^15.7.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-react": "^7.36.1", + "standard-engine": "^15.1.0", + "version-guard": "^1.1.1" + }, + "bin": { + "standard": "bin/cmd.cjs" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/standard-engine": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-15.1.0.tgz", + "integrity": "sha512-VHysfoyxFu/ukT+9v49d4BRXIokFRZuH3z1VRxzFArZdjSCFpro6rEIU3ji7e4AoAtuSfKBkiOmsrDqKW5ZSRw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "get-stdin": "^8.0.0", + "minimist": "^1.2.6", + "pkg-conf": "^3.1.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/stdin-discarder": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", + "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/version-guard": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/version-guard/-/version-guard-1.1.3.tgz", + "integrity": "sha512-JwPr6erhX53EWH/HCSzfy1tTFrtPXUe927wdM1jqBBeYp1OM+qPHjWbsvv6pIBduqdgxxS+ScfG7S28pzyr2DQ==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=0.10.48" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..03a30e6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/renderer.md b/renderer.md new file mode 100644 index 0000000..5edd0a6 --- /dev/null +++ b/renderer.md @@ -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 diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..c15a928 --- /dev/null +++ b/rollup.config.js @@ -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) +}] diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..9500ba7 --- /dev/null +++ b/src/index.js @@ -0,0 +1,66 @@ +/// +/** 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 diff --git a/src/structs/AbstractStruct.js b/src/structs/AbstractStruct.js new file mode 100644 index 0000000..9673531 --- /dev/null +++ b/src/structs/AbstractStruct.js @@ -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) diff --git a/src/structs/GC.js b/src/structs/GC.js new file mode 100644 index 0000000..b478c43 --- /dev/null +++ b/src/structs/GC.js @@ -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 diff --git a/src/structs/Item.js b/src/structs/Item.js new file mode 100644 index 0000000..6648444 --- /dev/null +++ b/src/structs/Item.js @@ -0,0 +1,1517 @@ +import * as error from 'lib0/error' +import * as binary from 'lib0/binary' +import * as env from 'lib0/environment' +import * as object from 'lib0/object' + +import { AbstractStruct, addStructToIdSet } from '../structs/AbstractStruct.js' + +import { ID, createID, compareIDs, findRootTypeKey } from '../utils/ID.js' +import { GC } from '../structs/GC.js' + +import { + replaceStruct, + getItemCleanEnd, + addChangedTypeToTransaction +} from '../utils/transaction-helpers.js' + +const isDevMode = env.getVariable('node_env') === 'development' + +/** + * @todo This should return several items + * + * @param {StructStore} store + * @param {ID} id + * @return {{item:Item, diff:number}} + */ +export const followRedone = (store, id) => { + /** + * @type {ID|null} + */ + let nextID = id + let diff = 0 + let item + do { + if (diff > 0) { + nextID = createID(nextID.client, nextID.clock + diff) + } + item = store.getItem(nextID) + diff = nextID.clock - item.id.clock + nextID = item.redone + } while (nextID !== null && item.isItem) + return { + item, diff + } +} + +/** + * Abstract class that represents any content. + */ +export class Item extends AbstractStruct { + /** + * @param {ID} id + * @param {Item | null} left + * @param {ID | null} origin + * @param {Item | null} right + * @param {ID | null} rightOrigin + * @param {YNode|ID|string|null} parent Is a type if integrated, is null if it is possible to copy parent from left or right, is ID before integration to search for it, is string if child of top-level-parent + * @param {string | null} parentSub + * @param {AbstractContent} content + */ + constructor (id, left, origin, right, rightOrigin, parent, parentSub, content) { + super(id, content.getLength()) + /** + * The item that was originally to the left of this item. + * @type {ID | null} + */ + this.origin = origin + /** + * The item that is currently to the left of this item. + * @type {Item | null} + */ + this.left = left + /** + * The item that is currently to the right of this item. + * @type {Item | null} + */ + this.right = right + /** + * The item that was originally to the right of this item. + * @type {ID | null} + */ + this.rightOrigin = rightOrigin + /** + * @type {YNode|ID|string|null} + */ + this.parent = parent + /** + * If the parent refers to this item with some kind of key (e.g. YMap, the + * key is specified here. The key is then used to refer to the list in which + * to insert this item. If `parentSub = null` type._start is the list in + * which to insert to. Otherwise it is `parent._map`. + * @type {String | null} + */ + this.parentSub = parentSub + /** + * If this type's effect is redone this type refers to the type that undid + * this operation. + * @type {ID | null} + */ + this.redone = null + /** + * @type {AbstractContent} + */ + this.content = content + /** + * bit1: keep + * bit2: countable + * bit3: deleted + * bit4: mark - mark node as fast-search-marker + * @type {number} byte + */ + this.info = this.content.isCountable() ? binary.BIT2 : 0 + } + + /** + * This is used to mark the item as an indexed fast-search marker + * + * @type {boolean} + */ + set marker (isMarked) { + if (((this.info & binary.BIT4) > 0) !== isMarked) { + this.info ^= binary.BIT4 + } + } + + get marker () { + return (this.info & binary.BIT4) > 0 + } + + /** + * If true, do not garbage collect this Item. + */ + get keep () { + return (this.info & binary.BIT1) > 0 + } + + set keep (doKeep) { + if (this.keep !== doKeep) { + this.info ^= binary.BIT1 + } + } + + get countable () { + return (this.info & binary.BIT2) > 0 + } + + /** + * Whether this item was deleted or not. + * @type {Boolean} + */ + get deleted () { + return (this.info & binary.BIT3) > 0 + } + + set deleted (doDelete) { + if (this.deleted !== doDelete) { + this.info ^= binary.BIT3 + } + } + + markDeleted () { + this.info |= binary.BIT3 + } + + /** + * @param {Transaction} transaction + * @param {number} offset + */ + integrate (transaction, offset) { + if (offset > 0) { + this.id.clock += offset + this.left = getItemCleanEnd(transaction, transaction.doc.store, createID(this.id.client, this.id.clock - 1)) + this.origin = this.left.lastId + this.content = this.content.splice(offset) + this.length -= offset + } + + if (this.parent) { + if ((!this.left && (!this.right || this.right.left !== null)) || (this.left && this.left.right !== this.right)) { + /** + * @type {Item|null} + */ + let left = this.left + + /** + * @type {Item|null} + */ + let o + // set o to the first conflicting item + if (left !== null) { + o = left.right + } else if (this.parentSub !== null) { + o = /** @type {YNode} */ (this.parent)._map.get(this.parentSub) || null + while (o !== null && o.left !== null) { + o = o.left + } + } else { + o = /** @type {YNode} */ (this.parent)._start + } + // TODO: use something like DeleteSet here (a tree implementation would be best) + // @todo use global set definitions + /** + * @type {Set} + */ + const conflictingItems = new Set() + /** + * @type {Set} + */ + const itemsBeforeOrigin = new Set() + // Let c in conflictingItems, b in itemsBeforeOrigin + // ***{origin}bbbb{this}{c,b}{c,b}{o}*** + // Note that conflictingItems is a subset of itemsBeforeOrigin + while (o !== null && o !== this.right) { + itemsBeforeOrigin.add(o) + conflictingItems.add(o) + if (compareIDs(this.origin, o.origin)) { + // case 1 + if (o.id.client < this.id.client) { + left = o + conflictingItems.clear() + } else if (compareIDs(this.rightOrigin, o.rightOrigin)) { + // this and o are conflicting and point to the same integration points. The id decides which item comes first. + // Since this is to the left of o, we can break here + break + } // else, o might be integrated before an item that this conflicts with. If so, we will find it in the next iterations + } else if (o.origin !== null && itemsBeforeOrigin.has(transaction.doc.store.getItem(o.origin))) { // use getItem instead of getItemCleanEnd because we don't want / need to split items. + // case 2 + if (!conflictingItems.has(transaction.doc.store.getItem(o.origin))) { + left = o + conflictingItems.clear() + } + } else { + break + } + o = o.right + } + this.left = left + } + // reconnect left/right + update parent map/start if necessary + if (this.left !== null) { + const right = this.left.right + this.right = right + this.left.right = this + } else { + let r + if (this.parentSub !== null) { + r = /** @type {YNode} */ (this.parent)._map.get(this.parentSub) || null + while (r !== null && r.left !== null) { + r = r.left + } + } else { + r = /** @type {YNode} */ (this.parent)._start + ;/** @type {YNode} */ (this.parent)._start = this + } + this.right = r + } + if (this.right !== null) { + this.right.left = this + } else if (this.parentSub !== null) { + // set as current parent value if right === null and this is parentSub + /** @type {YNode} */ (this.parent)._map.set(this.parentSub, this) + if (this.left !== null) { + // this is the current attribute value of parent. delete the previous value + this.left.delete(transaction) + } + } + // adjust length of parent + if (this.parentSub === null && this.countable && !this.deleted) { + /** @type {YNode} */ (this.parent)._length += this.length + } + addStructToIdSet(transaction.insertSet, this) + transaction.doc.store.add(this) + this.content.integrate(transaction, this) + // add parent to transaction.changed + addChangedTypeToTransaction(transaction, /** @type {YNode} */ (this.parent), this.parentSub) + if ((/** @type {YNode} */ (this.parent)._item !== null && /** @type {YNode} */ (this.parent)._item.deleted) || (this.parentSub !== null && this.right !== null)) { + // delete if parent is deleted or if this is not the current attribute value of parent + this.delete(transaction) + } + } else { + // parent is not defined. Integrate GC struct instead + new GC(this.id, this.length).integrate(transaction, 0) + } + } + + /** + * Returns the next non-deleted item + */ + get next () { + let n = this.right + while (n !== null && n.deleted) { + n = n.right + } + return n + } + + /** + * Returns the previous non-deleted item + */ + get prev () { + let n = this.left + while (n !== null && n.deleted) { + n = n.left + } + return n + } + + /** + * Computes the last content address of this Item. + */ + get lastId () { + // allocating ids is pretty costly because of the amount of ids created, so we try to reuse whenever possible + return this.length === 1 ? this.id : createID(this.id.client, this.id.clock + this.length - 1) + } + + /** + * Try to merge two items + * + * @param {Item} right + * @return {boolean} + */ + mergeWith (right) { + if ( + this.constructor === right.constructor && + compareIDs(right.origin, this.lastId) && + this.right === right && + compareIDs(this.rightOrigin, right.rightOrigin) && + this.id.client === right.id.client && + this.id.clock + this.length === right.id.clock && + this.deleted === right.deleted && + this.redone === null && + right.redone === null && + this.content.constructor === right.content.constructor && + this.content.mergeWith(right.content) + ) { + const searchMarker = /** @type {YNode} */ (this.parent)._searchMarker + if (searchMarker) { + searchMarker.forEach(marker => { + if (marker.p === right) { + // right is going to be "forgotten" so we need to update the marker + marker.p = this + // adjust marker index + if (!this.deleted && this.countable) { + marker.index -= this.length + } + } + }) + } + if (right.keep) { + this.keep = true + } + this.right = right.right + if (this.right !== null) { + this.right.left = this + } + this.length += right.length + return true + } + return false + } + + /** + * Mark this Item as deleted. + * + * @param {Transaction} transaction + */ + delete (transaction) { + if (!this.deleted) { + const parent = /** @type {YNode} */ (this.parent) + // adjust the length of parent + if (this.countable && this.parentSub === null) { + parent._length -= this.length + } + this.markDeleted() + transaction.deleteSet.add(this.id.client, this.id.clock, this.length) + addChangedTypeToTransaction(transaction, parent, this.parentSub) + this.content.delete(transaction) + } + } + + /** + * @param {Transaction} tr + * @param {boolean} parentGCd + */ + gc (tr, parentGCd) { + if (!this.deleted) { + throw error.unexpectedCase() + } + this.content.gc(tr) + if (parentGCd) { + replaceStruct(tr, this, new GC(this.id, this.length)) + } else { + this.content = new ContentDeleted(this.length) + } + } + + /** + * Split this into two items + * @param {Transaction?} transaction + * @param {number} diff + * @return {Item} + */ + split (transaction, diff) { + // create rightItem + const { client, clock } = this.id + const rightItem = new Item( + createID(client, clock + diff), + this, + createID(client, clock + diff - 1), + this.right, + this.rightOrigin, + this.parent, + this.parentSub, + this.content.splice(diff) + ) + if (this.deleted) { + rightItem.markDeleted() + } + if (this.keep) { + rightItem.keep = true + } + if (this.redone !== null) { + rightItem.redone = createID(this.redone.client, this.redone.clock + diff) + } + if (transaction != null) { + // update left (do not set leftItem.rightOrigin as it will lead to problems when syncing) + this.right = rightItem + // update right + if (rightItem.right !== null) { + rightItem.right.left = rightItem + } + // right is more specific. + transaction._mergeStructs.push(rightItem) + // update parent._map + if (rightItem.parentSub !== null && rightItem.right === null) { + /** @type {YNode} */ (rightItem.parent)._map.set(rightItem.parentSub, rightItem) + } + } else { + rightItem.left = null + rightItem.right = null + } + this.length = diff + return rightItem + } + + /** + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + * @param {number} offset + * @param {number} offsetEnd + */ + write (encoder, offset, offsetEnd) { + const origin = offset > 0 ? createID(this.id.client, this.id.clock + offset - 1) : this.origin + const rightOrigin = this.rightOrigin + const parentSub = this.parentSub + const info = (this.content.getRef() & binary.BITS5) | + (origin === null ? 0 : binary.BIT8) | // origin is defined + (rightOrigin === null ? 0 : binary.BIT7) | // right origin is defined + (parentSub === null ? 0 : binary.BIT6) // parentSub is non-null + encoder.writeInfo(info) + if (origin !== null) { + encoder.writeLeftID(origin) + } + if (rightOrigin !== null) { + encoder.writeRightID(rightOrigin) + } + if (origin === null && rightOrigin === null) { + const parent = /** @type {YNode} */ (this.parent) + if (parent._item !== undefined) { + const parentItem = parent._item + if (parentItem === null) { + // parent type on y._map + // find the correct key + const ykey = findRootTypeKey(parent) + encoder.writeParentInfo(true) // write parentYKey + encoder.writeString(ykey) + } else { + encoder.writeParentInfo(false) // write parent id + encoder.writeLeftID(parentItem.id) + } + } else if (parent.constructor === String) { // this edge case was added by differential updates + encoder.writeParentInfo(true) // write parentYKey + encoder.writeString(parent) + } else if (parent.constructor === ID) { + encoder.writeParentInfo(false) // write parent id + encoder.writeLeftID(parent) + } else { + error.unexpectedCase() + } + if (parentSub !== null) { + encoder.writeString(parentSub) + } + } + this.content.write(encoder, offset, offsetEnd) + } + + get ref () { + return this.content.getRef() + } +} + +/** + * @type {true} + */ +Item.prototype.isItem = true + +/** + * Do not implement this class! + */ +export class AbstractContent { + /** + * @return {number} + */ + getLength () { + throw error.methodUnimplemented() + } + + /** + * @return {Array} + */ + getContent () { + throw error.methodUnimplemented() + } + + /** + * Should return false if this Item is some kind of meta information + * (e.g. format information). + * + * * Whether this Item should be addressable via `yarray.get(i)` + * * Whether this Item should be counted when computing yarray.length + * + * @return {boolean} + */ + isCountable () { + throw error.methodUnimplemented() + } + + /** + * @return {AbstractContent} + */ + copy () { + throw error.methodUnimplemented() + } + + /** + * @param {number} _offset + * @return {AbstractContent} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {AbstractContent} _right + * @return {boolean} + */ + mergeWith (_right) { + throw error.methodUnimplemented() + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) { + throw error.methodUnimplemented() + } + + /** + * @param {Transaction} _transaction + */ + delete (_transaction) { + throw error.methodUnimplemented() + } + + /** + * @param {Transaction} _transaction + */ + gc (_transaction) { + throw error.methodUnimplemented() + } + + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} _encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (_encoder, _offset, _offsetEnd) { + throw error.methodUnimplemented() + } + + /** + * @return {1|2|3|4|5|6|7|8|9} + */ + getRef () { + throw error.methodUnimplemented() + } +} + +export class ContentAny { + /** + * @param {Array} arr + */ + constructor (arr) { + /** + * @type {Array} + */ + this.arr = arr + isDevMode && object.deepFreeze(arr) + } + + /** + * @return {number} + */ + getLength () { + return this.arr.length + } + + /** + * @return {Array} + */ + getContent () { + return this.arr + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentAny} + */ + copy () { + return new ContentAny(this.arr) + } + + /** + * @param {number} offset + * @return {ContentAny} + */ + splice (offset) { + const right = new ContentAny(this.arr.slice(offset)) + this.arr = this.arr.slice(0, offset) + return right + } + + /** + * @param {ContentAny} right + * @return {boolean} + */ + mergeWith (right) { + this.arr = this.arr.concat(right.arr) + return true + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) {} + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + * @param {number} offsetEnd + */ + write (encoder, offset, offsetEnd) { + const end = this.arr.length - offsetEnd + encoder.writeLen(end - offset) + for (let i = offset; i < end; i++) { + const c = this.arr[i] + encoder.writeAny(c) + } + } + + /** + * @return {8} + */ + getRef () { + return 8 + } +} + +export class ContentBinary { + /** + * @param {Uint8Array} content + */ + constructor (content) { + this.content = content + } + + /** + * @return {number} + */ + getLength () { + return 1 + } + + /** + * @return {Array} + */ + getContent () { + return [this.content] + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentBinary} + */ + copy () { + return new ContentBinary(this.content) + } + + /** + * @param {number} _offset + * @return {ContentBinary} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {ContentBinary} _right + * @return {boolean} + */ + mergeWith (_right) { + return false + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) {} + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (encoder, _offset, _offsetEnd) { + encoder.writeBuf(this.content) + } + + /** + * @return {3} + */ + getRef () { + return 3 + } +} + +export class ContentDeleted { + /** + * @param {number} len + */ + constructor (len) { + this.len = len + } + + /** + * @return {number} + */ + getLength () { + return this.len + } + + /** + * @return {Array} + */ + getContent () { + return [] + } + + /** + * @return {boolean} + */ + isCountable () { + return false + } + + /** + * @return {ContentDeleted} + */ + copy () { + return new ContentDeleted(this.len) + } + + /** + * @param {number} offset + * @return {ContentDeleted} + */ + splice (offset) { + const right = new ContentDeleted(this.len - offset) + this.len = offset + return right + } + + /** + * @param {ContentDeleted} right + * @return {boolean} + */ + mergeWith (right) { + this.len += right.len + return true + } + + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate (transaction, item) { + transaction.deleteSet.add(item.id.client, item.id.clock, this.len) + item.markDeleted() + } + + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + * @param {number} offsetEnd + */ + write (encoder, offset, offsetEnd) { + encoder.writeLen(this.len - offset - offsetEnd) + } + + /** + * @return {1} + */ + getRef () { + return 1 + } +} + +/** + * @private + */ +export class ContentDoc { + /** + * @param {string} guid + * @param {Object} opts + */ + constructor (guid, opts) { + /** + * @type {Doc?} + */ + this.doc = null + this.guid = guid + this.opts = opts + } + + /** + * @return {number} + */ + getLength () { + return 1 + } + + /** + * @return {Array} + */ + getContent () { + return [this.doc] + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentDoc} + */ + copy () { + return new ContentDoc(this.guid, this.opts) + } + + /** + * @param {number} _offset + * @return {ContentDoc} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {ContentDoc} _right + * @return {boolean} + */ + mergeWith (_right) { + return false + } + + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate (transaction, item) { + const opts = this.opts + if (this.doc == null) { + // we get the constructor from the existing doc to avoid import the doc module, leading to a + // circular dependency + this.doc = /** @type {Doc} */ (new /** @type {any} */ (transaction.doc.constructor)({ guid: this.guid, ...this.opts, shouldLoad: opts.shouldLoad || opts.autoLoad || false })) + } + this.doc._item = item + transaction.subdocsAdded.add(this.doc) + if (this.doc.shouldLoad) { + transaction.subdocsLoaded.add(this.doc) + } + } + + /** + * @param {Transaction} transaction + */ + delete (transaction) { + if (this.doc) { + if (transaction.subdocsAdded.has(this.doc)) { + transaction.subdocsAdded.delete(this.doc) + } else { + transaction.subdocsRemoved.add(this.doc) + } + } + } + + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (encoder, _offset, _offsetEnd) { + encoder.writeString(this.guid) + encoder.writeAny(this.opts) + } + + /** + * @return {9} + */ + getRef () { + return 9 + } +} + +/** + * @param {Doc} ydoc + */ +export const createContentDocFromDoc = ydoc => { + /** + * @type {any} + */ + const opts = {} + if (!ydoc.gc) { + opts.gc = false + } + if (ydoc.autoLoad) { + opts.autoLoad = true + } + if (ydoc.meta !== null) { + opts.meta = ydoc.meta + } + const c = new ContentDoc(ydoc.guid, opts) + c.doc = ydoc + return c +} + +/** + * @private + */ +export class ContentEmbed { + /** + * @param {Object} embed + */ + constructor (embed) { + this.embed = embed + } + + /** + * @return {number} + */ + getLength () { + return 1 + } + + /** + * @return {Array} + */ + getContent () { + return [this.embed] + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentEmbed} + */ + copy () { + return new ContentEmbed(this.embed) + } + + /** + * @param {number} _offset + * @return {ContentEmbed} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {ContentEmbed} _right + * @return {boolean} + */ + mergeWith (_right) { + return false + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) {} + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (encoder, _offset, _offsetEnd) { + encoder.writeJSON(this.embed) + } + + /** + * @return {5} + */ + getRef () { + return 5 + } +} + +/** + * @private + */ +export class ContentFormat { + /** + * @param {string} key + * @param {Object} value + */ + constructor (key, value) { + this.key = key + this.value = value + } + + /** + * @return {number} + */ + getLength () { + return 1 + } + + /** + * @return {Array} + */ + getContent () { + return [] + } + + /** + * @return {boolean} + */ + isCountable () { + return false + } + + /** + * @return {ContentFormat} + */ + copy () { + return new ContentFormat(this.key, this.value) + } + + /** + * @param {number} _offset + * @return {ContentFormat} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {ContentFormat} _right + * @return {boolean} + */ + mergeWith (_right) { + return false + } + + /** + * @param {Transaction} _transaction + * @param {Item} item + */ + integrate (_transaction, item) { + // @todo searchmarker are currently unsupported for rich text documents + const p = /** @type {import('../ynode.js').YNode} */ (item.parent) + p._searchMarker = null + p._hasFormatting = true + } + + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (encoder, _offset, _offsetEnd) { + encoder.writeKey(this.key) + encoder.writeJSON(this.value) + } + + /** + * @return {6} + */ + getRef () { + return 6 + } +} + +/** + * @private + */ +export class ContentJSON { + /** + * @param {Array} arr + */ + constructor (arr) { + /** + * @type {Array} + */ + this.arr = arr + } + + /** + * @return {number} + */ + getLength () { + return this.arr.length + } + + /** + * @return {Array} + */ + getContent () { + return this.arr + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentJSON} + */ + copy () { + return new ContentJSON(this.arr) + } + + /** + * @param {number} offset + * @return {ContentJSON} + */ + splice (offset) { + const right = new ContentJSON(this.arr.slice(offset)) + this.arr = this.arr.slice(0, offset) + return right + } + + /** + * @param {ContentJSON} right + * @return {boolean} + */ + mergeWith (right) { + this.arr = this.arr.concat(right.arr) + return true + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) {} + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + * @param {number} offsetEnd + */ + write (encoder, offset, offsetEnd) { + const end = this.arr.length - offsetEnd + encoder.writeLen(end - offset) + for (let i = offset; i < end; i++) { + const c = this.arr[i] + encoder.writeString(c === undefined ? 'undefined' : JSON.stringify(c)) + } + } + + /** + * @return {2} + */ + getRef () { + return 2 + } +} + +/** + * @private + */ +export class ContentString { + /** + * @param {string} str + */ + constructor (str) { + /** + * @type {string} + */ + this.str = str + } + + /** + * @return {number} + */ + getLength () { + return this.str.length + } + + /** + * @return {Array} + */ + getContent () { + return this.str.split('') + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentString} + */ + copy () { + return new ContentString(this.str) + } + + /** + * @param {number} offset + * @return {ContentString} + */ + splice (offset) { + const right = new ContentString(this.str.slice(offset)) + this.str = this.str.slice(0, offset) + + // Prevent encoding invalid documents because of splitting of surrogate pairs: https://github.com/yjs/yjs/issues/248 + const firstCharCode = this.str.charCodeAt(offset - 1) + if (firstCharCode >= 0xD800 && firstCharCode <= 0xDBFF) { + // Last character of the left split is the start of a surrogate utf16/ucs2 pair. + // We don't support splitting of surrogate pairs because this may lead to invalid documents. + // Replace the invalid character with a unicode replacement character (� / U+FFFD) + this.str = this.str.slice(0, offset - 1) + '�' + // replace right as well + right.str = '�' + right.str.slice(1) + } + return right + } + + /** + * @param {ContentString} right + * @return {boolean} + */ + mergeWith (right) { + this.str += right.str + return true + } + + /** + * @param {Transaction} _transaction + * @param {Item} _item + */ + integrate (_transaction, _item) {} + /** + * @param {Transaction} _transaction + */ + delete (_transaction) {} + /** + * @param {Transaction} _tr + */ + gc (_tr) {} + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} offset + * @param {number} offsetEnd + */ + write (encoder, offset, offsetEnd) { + encoder.writeString((offset === 0 && offsetEnd === 0) ? this.str : this.str.slice(offset, this.str.length - offsetEnd)) + } + + /** + * @return {4} + */ + getRef () { + return 4 + } +} + +export const YArrayRefID = 0 +export const YMapRefID = 1 +export const YTextRefID = 2 +export const YXmlElementRefID = 3 +export const YXmlFragmentRefID = 4 +export const YXmlHookRefID = 5 +export const YXmlTextRefID = 6 + +/** + * @private + */ +export class ContentType { + /** + * @param {import('../ynode.js').YNode} type + */ + constructor (type) { + /** + * @type {import('../ynode.js').YNode} + */ + this.type = type + } + + /** + * @return {number} + */ + getLength () { + return 1 + } + + /** + * @return {Array} + */ + getContent () { + return [this.type] + } + + /** + * @return {boolean} + */ + isCountable () { + return true + } + + /** + * @return {ContentType} + */ + copy () { + return new ContentType(this.type._copy()) + } + + /** + * @param {number} _offset + * @return {ContentType} + */ + splice (_offset) { + throw error.methodUnimplemented() + } + + /** + * @param {ContentType} _right + * @return {boolean} + */ + mergeWith (_right) { + return false + } + + /** + * @param {Transaction} transaction + * @param {Item} item + */ + integrate (transaction, item) { + this.type._integrate(transaction.doc, item) + } + + /** + * @param {Transaction} transaction + */ + delete (transaction) { + let item = this.type._start + while (item !== null) { + if (!item.deleted) { + item.delete(transaction) + } else if (!transaction.insertSet.hasId(item.id)) { + // This will be gc'd later and we want to merge it if possible + // We try to merge all deleted items after each transaction, + // but we have no knowledge about that this needs to be merged + // since it is not in transaction.ds. Hence we add it to transaction._mergeStructs + transaction._mergeStructs.push(item) + } + item = item.right + } + this.type._map.forEach(item => { + if (!item.deleted) { + item.delete(transaction) + } else if (!transaction.insertSet.hasId(item.id)) { + // same as above + transaction._mergeStructs.push(item) + } + }) + } + + /** + * @param {Transaction} tr + */ + gc (tr) { + let item = this.type._start + while (item !== null) { + item.gc(tr, true) + item = item.right + } + this.type._start = null + this.type._map.forEach(/** @param {Item | null} item */ (item) => { + while (item !== null) { + item.gc(tr, true) + item = item.left + } + }) + this.type._map = new Map() + } + + /** + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder + * @param {number} _offset + * @param {number} _offsetEnd + */ + write (encoder, _offset, _offsetEnd) { + this.type._write(encoder) + } + + /** + * @return {7} + */ + getRef () { + return 7 + } +} diff --git a/src/structs/Skip.js b/src/structs/Skip.js new file mode 100644 index 0000000..d596252 --- /dev/null +++ b/src/structs/Skip.js @@ -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 diff --git a/src/utils/BlockSet.js b/src/utils/BlockSet.js new file mode 100644 index 0000000..5c95484 --- /dev/null +++ b/src/utils/BlockSet.js @@ -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} + */ + 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} refs + */ + constructor (refs) { + this.i = 0 + /** + * @type {Array} + */ + this.refs = refs + } +} + +export class BlockSet { + constructor () { + /** + * @type {Map} + */ + 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} + */ + 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() + } +} diff --git a/src/utils/Doc.js b/src/utils/Doc.js new file mode 100644 index 0000000..7a31c94 --- /dev/null +++ b/src/utils/Doc.js @@ -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, any, Doc, Transaction):void} DocEvents.update + * @property {function(Uint8Array, any, Doc, Transaction):void} DocEvents.updateV2 + * @property {function(Doc):void} DocEvents.beforeAllTransactions + * @property {function(Transaction, Doc):void} DocEvents.beforeTransaction + * @property {function(Transaction, Doc):void} DocEvents.beforeObserverCalls + * @property {function(Transaction, Doc):void} DocEvents.afterTransaction + * @property {function(Transaction, Doc):void} DocEvents.afterTransactionCleanup + * @property {function(Doc, Array):void} DocEvents.afterAllTransactions + * @property {function({ loaded: Set, added: Set, removed: Set }, Doc, Transaction):void} DocEvents.subdocs + */ + +/** + * A Yjs instance handles the state of shared data. + * @extends ObservableV2 + */ +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} + */ + this.share = new Map() + this.store = new StructStore() + /** + * @type {Transaction | null} + */ + this._transaction = null + /** + * @type {Array} + */ + this._transactionCleanups = [] + /** + * @type {Set} + */ + 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} + */ + toJSON () { + /** + * @type {Object} + */ + 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 diff --git a/src/utils/EventHandler.js b/src/utils/EventHandler.js new file mode 100644 index 0000000..33fa6f2 --- /dev/null +++ b/src/utils/EventHandler.js @@ -0,0 +1,87 @@ +import * as f from 'lib0/function' + +/** + * General event handler implementation. + * + * @template ARG0, ARG1 + * + * @private + */ +export class EventHandler { + constructor () { + /** + * @type {Array} + */ + this.l = [] + } +} + +/** + * @template ARG0,ARG1 + * @returns {EventHandler} + * + * @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} 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} 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} 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} eventHandler + * @param {ARG0} arg0 + * @param {ARG1} arg1 + * + * @private + * @function + */ +export const callEventHandlerListeners = (eventHandler, arg0, arg1) => + f.callAll(eventHandler.l, [arg0, arg1]) diff --git a/src/utils/ID.js b/src/utils/ID.js new file mode 100644 index 0000000..7daefff --- /dev/null +++ b/src/utils/ID.js @@ -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} 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() +} diff --git a/src/utils/RelativePosition.js b/src/utils/RelativePosition.js new file mode 100644 index 0000000..6047929 --- /dev/null +++ b/src/utils/RelativePosition.js @@ -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} type + * @param {number} index + * @param {number} [assoc] + */ + constructor (type, index, assoc = 0) { + /** + * @type {YNode} + */ + this.type = type + /** + * @type {number} + */ + this.index = index + this.assoc = assoc + } +} + +/** + * @param {YNode} type + * @param {number} index + * @param {number} [assoc] + * + * @function + */ +export const createAbsolutePosition = (type, index, assoc = 0) => new AbsolutePosition(type, index, assoc) + +/** + * @param {YNode} 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} */ (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 +) diff --git a/src/utils/Renderer.js b/src/utils/Renderer.js new file mode 100644 index 0000000..09db519 --- /dev/null +++ b/src/utils/Renderer.js @@ -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>} 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>|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} + */ + 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>} 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?} + */ + 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} + */ + 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} + */ + 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|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?} + */ + 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>} 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>} + */ + 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} [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>} 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) diff --git a/src/utils/Snapshot.js b/src/utils/Snapshot.js new file mode 100644 index 0000000..0302c21 --- /dev/null +++ b/src/utils/Snapshot.js @@ -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} sv state map + */ + constructor (ds, sv) { + /** + * @type {IdSet} + */ + this.ds = ds + /** + * State Map + * @type {Map} + */ + 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} 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) diff --git a/src/utils/StructStore.js b/src/utils/StructStore.js new file mode 100644 index 0000000..3b2acfa --- /dev/null +++ b/src/utils/StructStore.js @@ -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>} + */ + this.clients = new Map() + // this.ds = new IdSet() + /** + * @type {null | { missing: Map, update: Uint8Array }} + */ + this.pendingStructs = null + /** + * @type {null | Uint8Array} + */ + 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} */ (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} */ (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, 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. + * Note that clock refers to the next expected clock id. + * + * @param {StructStore} store + * @return {Map} + * + * @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') + } + } + }) +} diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js new file mode 100644 index 0000000..c9ad82f --- /dev/null +++ b/src/utils/Transaction.js @@ -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?} + */ + this._beforeState = null + /** + * Holds the state after the transaction. + * @type {Map?} + */ + 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>} + */ + this.changed = new Map() + /** + * Stores the events for the types that observe also child elements. + * It is mainly used by `observeDeep`. + * @type {Map>>} + */ + this.changedParentTypes = new Map() + /** + * @type {Array} + */ + this._mergeStructs = [] + /** + * @type {any} + */ + this.origin = origin + /** + * Stores meta information on the transaction + * @type {Map} + */ + this.meta = new Map() + /** + * Whether this change originates from this doc. + * @type {boolean} + */ + this.local = local + /** + * @type {Set} + */ + this.subdocsAdded = new Set() + /** + * @type {Set} + */ + this.subdocsRemoved = new Set() + /** + * @type {Set} + */ + this.subdocsLoaded = new Set() + /** + * @type {boolean} + */ + this._needFormattingCleanup = false + this._done = false + } + + /** + * Holds the state before the transaction started. + * + * @deprecated + * @type {Map} + */ + 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} + */ + 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} 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} + */ + 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} + */ + 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} */ (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} */ (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} + */ + 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 +} diff --git a/src/utils/UndoManager.js b/src/utils/UndoManager.js new file mode 100644 index 0000000..376f57f --- /dev/null +++ b/src/utils/UndoManager.js @@ -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} 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} + */ + const itemsToRedo = new Set() + /** + * @type {Array} + */ + 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} [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>>} 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} 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} + */ + this.scope = [] + this.doc = doc + this.addToScope(typeScope) + this.deleteFilter = deleteFilter + trackedOrigins.add(this) + this.trackedOrigins = trackedOrigins + this.captureTransaction = captureTransaction + /** + * @type {Array} + */ + this.undoStack = [] + /** + * @type {Array} + */ + 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} 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} 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} 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 + } +} diff --git a/src/utils/UpdateDecoder.js b/src/utils/UpdateDecoder.js new file mode 100644 index 0000000..dc1338c --- /dev/null +++ b/src/utils/UpdateDecoder.js @@ -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} + */ + 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 + } + } +} diff --git a/src/utils/UpdateEncoder.js b/src/utils/UpdateEncoder.js new file mode 100644 index 0000000..9d7361d --- /dev/null +++ b/src/utils/UpdateEncoder.js @@ -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} + */ + 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 + */ diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js new file mode 100644 index 0000000..709c4b5 --- /dev/null +++ b/src/utils/YEvent.js @@ -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} target The changed type. + * @param {import('./Transaction.js').Transaction} transaction + * @param {Set?} subs The keys that changed + */ + constructor (target, transaction, subs) { + /** + * The type on which this event was created on. + * @type {import('../ynode.js').YNode} + */ + this.target = target + /** + * The current target on which the observe callback is called. + * @type {YNode} + */ + this.currentTarget = target + /** + * The transaction that triggered this event. + * @type {import('./Transaction.js').Transaction} + */ + this.transaction = transaction + /** + * @type {Delta>|null} + */ + this._delta = null + /** + * @type {Delta|null} + */ + this._deltaDeep = null + /** + * Whether the children changed. + * @type {Boolean} + * @private + */ + this.childListChanged = false + /** + * Set of all changed attributes. + * @type {Set} + */ + 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 : Delta>} 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>|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>} 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} 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} 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 +} diff --git a/src/utils/content-helper.js b/src/utils/content-helper.js new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/delta-helpers.js b/src/utils/delta-helpers.js new file mode 100644 index 0000000..4741462 --- /dev/null +++ b/src/utils/delta-helpers.js @@ -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>} + */ + 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 +} diff --git a/src/utils/encoding-helpers.js b/src/utils/encoding-helpers.js new file mode 100644 index 0000000..096aa49 --- /dev/null +++ b/src/utils/encoding-helpers.js @@ -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} structs All structs by `client` + * @param {number} client + * @param {Array} 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} */ (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 +} diff --git a/src/utils/encoding.js b/src/utils/encoding.js new file mode 100644 index 0000000..cf0c143 --- /dev/null +++ b/src/utils/encoding.js @@ -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} _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} */ (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, missing: Map } } + * + * @private + * @function + */ +const integrateStructs = (transaction, store, clientsStructRefs) => { + /** + * @type {Array} + */ + 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}} */ (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}} */ (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, 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} [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} + * + * @function + */ +export const encodeStateAsUpdateV2 = (doc, encodedTargetStateVector = new Uint8Array([0]), encoder = new UpdateEncoderV2()) => { + const targetStateVector = decodeStateVector(encodedTargetStateVector) + writeStateAsUpdate(encoder, doc, targetStateVector) + const updates = [encoder.toUint8Array()] + // also add the pending updates (if there are any) + if (doc.store.pendingDs) { + updates.push(doc.store.pendingDs) + } + if (doc.store.pendingStructs) { + updates.push(diffUpdateV2(doc.store.pendingStructs.update, encodedTargetStateVector)) + } + if (updates.length > 1) { + if (encoder.constructor === UpdateEncoderV1) { + return mergeUpdates(updates.map((update, i) => i === 0 ? update : convertUpdateFormatV2ToV1(update))) + } else if (encoder.constructor === UpdateEncoderV2) { + return mergeUpdatesV2(updates) + } + } + return updates[0] +} + +/** + * Write all the document as a single update message that can be applied on the remote document. If you specify the state of the remote client (`targetState`) it will + * only write the operations that are missing. + * + * Use `writeStateAsUpdate` instead if you are working with lib0/encoding.js#Encoder + * + * @param {Doc} doc + * @param {Uint8Array} [encodedTargetStateVector] The state of the target that receives the update. Leave empty to write all known structs + * @return {Uint8Array} + * + * @function + */ +export const encodeStateAsUpdate = (doc, encodedTargetStateVector) => encodeStateAsUpdateV2(doc, encodedTargetStateVector, new UpdateEncoderV1()) + +/** + * Read state vector from Decoder and return as Map + * + * @param {IdSetDecoderV1 | IdSetDecoderV2} decoder + * @return {Map} 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>} updates + * @param {typeof UpdateDecoderV1 | typeof UpdateDecoderV2} [YDecoder] + * @param {typeof UpdateEncoderV1 | typeof UpdateEncoderV2} [YEncoder] + * @return {Uint8Array} + */ +export const mergeUpdatesV2 = (updates, YDecoder = UpdateDecoderV2, YEncoder = UpdateEncoderV2) => { + if (updates.length === 1) { + return updates[0] + } 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>} updates + * @return {Uint8Array} + */ +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} update + * @param {Uint8Array} sv + */ +export const diffUpdate = (update, sv) => diffUpdateV2(update, sv, UpdateDecoderV1, UpdateEncoderV1) + +/** + * Read decodedState and return State as Map. + * + * @param {Uint8Array} decodedState + * @return {Map} 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} 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} 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} doc + * @param {IdSetEncoderV1 | IdSetEncoderV2} [encoder] + * @return {Uint8Array} + * + * @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} doc + * @return {Uint8Array} + * + * @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 +} diff --git a/src/utils/ids.js b/src/utils/ids.js new file mode 100644 index 0000000..8f5b3e8 --- /dev/null +++ b/src/utils/ids.js @@ -0,0 +1,1752 @@ +import * as math from 'lib0/math' +import * as traits from 'lib0/traits' +import * as encoding from 'lib0/encoding' +import * as decoding from 'lib0/decoding' +import * as buf from 'lib0/buffer' +import * as rabin from 'lib0/hash/rabin' +import * as array from 'lib0/array' +import * as map from 'lib0/map' +import * as s from 'lib0/schema' + +import { iterateStructs, findIndexSS, iterateStructsWithoutSplits, tryGc } from './transaction-helpers.js' +import { UpdateEncoderV2, IdSetEncoderV2 } from './UpdateEncoder.js' +import { IdSetDecoderV2 } from './UpdateDecoder.js' + +/** + * @typedef {{ inserts: IdSet, deletes: IdSet }} ContentIds + */ + +/** + * @typedef {{ inserts: IdMap, deletes: IdMap }} ContentMap + */ + +export class IdRange { + /** + * @param {number} clock + * @param {number} len + */ + constructor (clock, len) { + /** + * @type {number} + */ + this.clock = clock + /** + * @type {number} + */ + this.len = len + } + + /** + * @param {number} clock + * @param {number} len + */ + copyWith (clock, len) { + return new IdRange(clock, len) + } + + /** + * Helper method making this compatible with IdMap. + * + * @return {Array>} + */ + get attrs () { + return [] + } +} + +export class MaybeIdRange { + /** + * @param {number} clock + * @param {number} len + * @param {boolean} exists + */ + constructor (clock, len, exists) { + /** + * @type {number} + */ + this.clock = clock + /** + * @type {number} + */ + this.len = len + /** + * @type {boolean} + */ + this.exists = exists + } +} + +/** + * @param {number} clock + * @param {number} len + * @param {boolean} exists + * @return {MaybeIdRange} + */ +export const createMaybeIdRange = (clock, len, exists) => new MaybeIdRange(clock, len, exists) + +export class IdRanges { + /** + * @param {Array} ids + */ + constructor (ids) { + this.sorted = false + /** + * A typical use-case for IdSet is to append data. We heavily optimize this case by allowing the + * last item to be mutated if it isn't used currently. + * This flag is true if the last item was exposed to the outside. + */ + this._lastIsUsed = false + /** + * @private + */ + this._ids = ids + } + + copy () { + const cpy = new IdRanges(this._ids.slice()) + cpy.sorted = this.sorted + this._lastIsUsed = true + return cpy + } + + /** + * @param {number} clock + * @param {number} length + */ + add (clock, length) { + const last = this._ids[this._ids.length - 1] + if (last != null && last.clock + last.len === clock) { + if (this._lastIsUsed) { + this._ids[this._ids.length - 1] = new IdRange(last.clock, last.len + length) + this._lastIsUsed = false + } else { + this._ids[this._ids.length - 1].len += length + } + } else { + this.sorted = false + this._ids.push(new IdRange(clock, length)) + } + } + + /** + * Return the list of immutable id ranges, sorted and merged. + */ + getIds () { + const ids = this._ids + this._lastIsUsed = true + if (!this.sorted) { + this.sorted = true + ids.sort((a, b) => a.clock - b.clock) + // merge items without filtering or splicing the array + // i is the current pointer + // j refers to the current insert position for the pointed item + // try to merge dels[i] into dels[j-1] or set dels[j]=dels[i] + let i, j + for (i = 1, j = 1; i < ids.length; i++) { + const left = ids[j - 1] + const right = ids[i] + if (left.clock + left.len >= right.clock) { + const r = right.clock + right.len - left.clock + if (left.len < r) { + ids[j - 1] = new IdRange(left.clock, r) + } + } else if (left.len === 0) { + ids[j - 1] = right + } else { + if (j < i) { + ids[j] = right + } + j++ + } + } + ids.length = ids[j - 1].len === 0 ? j - 1 : j + } + return ids + } + + /** + * Test a predicate against every attribute held by these ranges. Does not mutate - see + * {@link AttrRanges#everyAttr}. + * + * This exists because an `IdMap` may hold `IdRanges`: {@link insertIntoIdMap}'s `src` may be an + * `IdSet`, and `_insertIntoIdSet` picks the container class from whichever `src` reaches a client + * first. For a pure `IdRanges` the loop is vacuous ({@link IdRange#attrs} is always `[]`), but it + * must *not* shortcut to `true`: the append branch of `_insertIntoIdSet` copies elements without + * checking their class, so an `IdRanges` reached by an `IdSet` first and an `IdMap` second holds + * real `AttrRange`s - which {@link $idMap} must still validate. + * + * @param {(attr:ContentAttribute) => boolean} f + * @return {boolean} + */ + everyAttr (f) { + const ids = this._ids + for (let i = 0; i < ids.length; i++) { + const attrs = ids[i].attrs + if (attrs == null) continue + for (let j = 0; j < attrs.length; j++) { + if (!f(attrs[j])) return false + } + } + return true + } +} + +/** + * @implements {traits.EqualityTrait} + */ +export class IdSet { + constructor () { + /** + * @type {Map} + */ + this.clients = new Map() + } + + isEmpty () { + return this.clients.size === 0 + } + + /** + * @param {(idrange:IdRange, client:number) => void} f + */ + forEach (f) { + this.clients.forEach((ranges, client) => { + ranges.getIds().forEach((range) => { + f(range, client) + }) + }) + } + + /** + * @param {ID} id + * @return {boolean} + */ + hasId (id) { + return this.has(id.client, id.clock) + } + + /** + * @param {number} client + * @param {number} clock + */ + has (client, clock) { + const dr = this.clients.get(client) + if (dr) { + return findIndexInIdRanges(dr.getIds(), clock) !== null + } + return false + } + + /** + * Whether any id in the range `[clock, clock+len)` of `client` is contained in this set. + * Allocation-free (binary search). + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {boolean} + */ + intersects (client, clock, len) { + const dr = this.clients.get(client) + if (dr) { + const ranges = dr.getIds() + const index = findRangeStartInIdRanges(ranges, clock) + return index !== null && ranges[index].clock < clock + len + } + return false + } + + /** + * Whether the entire range `[clock, clock+len)` of `client` is contained in this set. + * Allocation-free (binary search). Because `getIds()` returns maximal, merged ranges, full + * coverage of a range implies it lies within a single range. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {boolean} + */ + covers (client, clock, len) { + const dr = this.clients.get(client) + if (dr) { + const ranges = dr.getIds() + const index = findIndexInIdRanges(ranges, clock) + if (index !== null) { + const r = ranges[index] + return clock + len <= r.clock + r.len + } + } + return false + } + + /** + * Total number of ids in `[clock, clock+len)` of `client` contained in this set. + * Allocation-free (binary search + linear scan of the overlapping ranges). Equivalent to + * `slice(client, clock, len).reduce((s, r) => r.exists ? s + r.len : s, 0)` without allocating. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {number} + */ + coveredLength (client, clock, len) { + const dr = this.clients.get(client) + let covered = 0 + if (dr) { + const ranges = dr.getIds() + const end = clock + len + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + for (; index < ranges.length; index++) { + const r = ranges[index] + if (r.clock >= end) break + const from = r.clock < clock ? clock : r.clock + const rEnd = r.clock + r.len + const to = rEnd > end ? end : rEnd + covered += to - from + } + } + } + return covered + } + + /** + * Return slices of ids that exist in this idset. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {Array} + */ + slice (client, clock, len) { + const dr = this.clients.get(client) + /** + * @type {Array} + */ + const res = [] + if (dr) { + /** + * @type {Array} + */ + const ranges = dr.getIds() + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + let prev = null + while (index < ranges.length) { + let r = ranges[index] + if (r.clock < clock) { + r = new IdRange(clock, r.len - (clock - r.clock)) + } + if (r.clock + r.len > clock + len) { + r = new IdRange(r.clock, clock + len - r.clock) + } + if (r.len <= 0) break + const prevEnd = prev != null ? prev.clock + prev.len : clock + if (prevEnd < r.clock) { + res.push(createMaybeIdRange(prevEnd, r.clock - prevEnd, false)) + } + prev = r + res.push(createMaybeIdRange(r.clock, r.len, true)) + index++ + } + } + } + if (res.length > 0) { + const last = res[res.length - 1] + const end = last.clock + last.len + if (end < clock + len) { + res.push(createMaybeIdRange(end, clock + len - end, false)) + } + } else { + res.push(createMaybeIdRange(clock, len, false)) + } + return res + } + + /** + * @param {number} client + * @param {number} clock + * @param {number} len + */ + add (client, clock, len) { + if (len === 0) return + const idRanges = this.clients.get(client) + if (idRanges) { + idRanges.add(clock, len) + } else { + this.clients.set(client, new IdRanges([new IdRange(clock, len)])) + } + } + + /** + * @param {number} client + * @param {number} clock + * @param {number} len + */ + delete (client, clock, len) { + _deleteRangeFromIdSet(this, client, clock, len) + } + + /** + * @param {any} other + */ + [traits.EqualityTraitSymbol] (other) { + return equalIdSets(this, other) + } +} + +/** + * @param {IdSet} ds1 + * @param {IdSet} ds2 + */ +export const equalIdSets = (ds1, ds2) => { + if (ds1.clients.size !== ds2.clients.size) return false + for (const [client, _deleteItems1] of ds1.clients.entries()) { + const deleteItems1 = _deleteItems1.getIds() + const deleteItems2 = ds2.clients.get(client)?.getIds() + if (deleteItems2 === undefined || deleteItems1.length !== deleteItems2.length) return false + for (let i = 0; i < deleteItems1.length; i++) { + const di1 = deleteItems1[i] + const di2 = deleteItems2[i] + if (di1.clock !== di2.clock || di1.len !== di2.len) { + return false + } + } + } + return true +} + +/** + * @param {IdSet | IdMap} set + * @param {number} client + * @param {number} clock + * @param {number} len + */ +export const _deleteRangeFromIdSet = (set, client, clock, len) => { + const dr = set.clients.get(client) + if (dr && len > 0) { + const ids = dr.getIds() + let index = findRangeStartInIdRanges(ids, clock) + if (index != null) { + for (let r = ids[index]; index < ids.length && r.clock < clock + len; r = ids[++index]) { + if (r.clock < clock) { + ids[index] = r.copyWith(r.clock, clock - r.clock) + if (clock + len < r.clock + r.len) { + ids.splice(index + 1, 0, r.copyWith(clock + len, r.clock + r.len - clock - len)) + } + } else if (clock + len < r.clock + r.len) { + // need to retain end + ids[index] = r.copyWith(clock + len, r.clock + r.len - clock - len) + } else if (ids.length === 1) { + set.clients.delete(client) + return + } else { + ids.splice(index--, 1) + } + } + } + } +} + +/** + * Iterate over all structs that are mentioned by the IdSet. + * + * @param {Transaction} transaction + * @param {IdSet} ds + * @param {function(GC|Item):void} f + * + * @function + */ +export const iterateStructsByIdSet = (transaction, ds, f) => + ds.clients.forEach((idRanges, clientid) => { + const ranges = idRanges.getIds() + const structs = /** @type {Array} */ (transaction.doc.store.clients.get(clientid)) + if (structs != null) { + for (let i = 0; i < ranges.length; i++) { + const del = ranges[i] + iterateStructs(transaction, structs, del.clock, del.len, f) + } + } + }) + +/** + * Garbage-collect the deleted content referenced by `ids` on `doc`. + * + * This is useful to retroactively reclaim memory on a document created with `gc: false` + * (e.g. once a snapshot, or an UndoManager StackItem, that referenced this content is no + * longer needed) - without enabling gc for the whole document. Ids that reference live + * (non-deleted) content, already-collected structs, items flagged with `keep`, or items + * rejected by `gcFilter` are skipped. + * + * @param {Doc} doc + * @param {IdSet} ids + * @param {function(Item):boolean} [gcFilter] + */ +export const gcIdSet = (doc, ids, gcFilter = doc.gcFilter) => + doc.transact(tr => { + // Split structs exactly at the IdSet boundaries so that only the referenced content is + // collected - an arbitrary IdSet need not align with struct boundaries. + iterateStructsByIdSet(tr, ids, () => {}) + tryGc(tr, ids, gcFilter) + }) + +/** + * Iterate over all structs that are mentioned by the IdSet, without spliting the items. + * + * @param {StructStore} store + * @param {IdSet} ds + * @param {(struct: GC|Item|Skip, offset:number, len:number)=>void} f + */ +export const iterateStructsByIdSetWithoutSplits = (store, ds, f) => + ds.clients.forEach((idRanges, clientid) => { + const ranges = idRanges.getIds() + const structs = /** @type {Array} */ (store.clients.get(clientid)) + if (structs != null) { + const lastStruct = structs[structs.length - 1] + const nextClock = lastStruct.id.clock + lastStruct.length + for (let i = 0; i < ranges.length; i++) { + const del = ranges[i] + if (del.clock < nextClock) { + iterateStructsWithoutSplits(structs, del.clock, del.len, f) + } + } + } + }) + +/** + * @param {Array} dis + * @param {number} clock + * @return {number|null} + * + * @private + * @function + */ +export const findIndexInIdRanges = (dis, clock) => { + let left = 0 + let right = dis.length - 1 + while (left <= right) { + const midindex = math.floor((left + right) / 2) + const mid = dis[midindex] + const midclock = mid.clock + if (midclock <= clock) { + if (clock < midclock + mid.len) { + return midindex + } + left = midindex + 1 + } else { + right = midindex - 1 + } + } + return null +} + +/** + * Find the first range that contains clock or comes after clock. + * + * @param {Array} dis + * @param {number} clock + * @return {number|null} + * + * @private + * @function + */ +export const findRangeStartInIdRanges = (dis, clock) => { + let left = 0 + let right = dis.length - 1 + while (left <= right) { + const midindex = math.floor((left + right) / 2) + const mid = dis[midindex] + const midclock = mid.clock + if (midclock <= clock) { + if (clock < midclock + mid.len) { + return midindex + } + left = midindex + 1 + } else { + right = midindex - 1 + } + } + return left < dis.length ? left : null +} + +/** + * @param {Array} idSets + * @return {IdSet} A fresh IdSet + */ +export const mergeIdSets = idSets => { + const merged = new IdSet() + for (let dssI = 0; dssI < idSets.length; dssI++) { + idSets[dssI].clients.forEach((rangesLeft, client) => { + if (!merged.clients.has(client)) { + // Write all missing keys from current ds and all following. + // If merged already contains `client` current ds has already been added. + const ids = rangesLeft.getIds().slice() + for (let i = dssI + 1; i < idSets.length; i++) { + const nextIds = idSets[i].clients.get(client) + if (nextIds) { + array.appendTo(ids, nextIds.getIds()) + } + } + merged.clients.set(client, new IdRanges(ids)) + } + }) + } + return merged +} + +/** + * @template {IdSet | IdMap} S + * @param {S} dest + * @param {S} src + */ +const _insertIntoIdSet = (dest, src) => { + src.clients.forEach((srcRanges, client) => { + const targetRanges = dest.clients.get(client) + if (targetRanges) { + array.appendTo(targetRanges.getIds(), srcRanges.getIds()) + targetRanges.sorted = false + } else { + // order first + srcRanges.getIds() + dest.clients.set(client, /** @type {any} */ (srcRanges.copy())) + } + }) +} + +/** + * @param {IdSet} dest + * @param {IdSet} src + */ +export const insertIntoIdSet = _insertIntoIdSet + +/** + * Note that `src` may be an `IdSet`, in which case `dest` ends up holding `IdRanges` - which carry + * no attributes ({@link IdRange#attrs} is `[]`). This is by design; {@link $idMap} tolerates it. + * + * @type {(dest: IdMap, src: IdMap|IdSet) => void} + */ +export const insertIntoIdMap = _insertIntoIdSet + +/** + * @todo rename to excludeIdSet | excludeIdMap + * + * @todo dispatch on the schema rather than `instanceof IdSet`: `const isMap = set.$type === + * $idMapAny`. Mind the polarity - it inverts. `instanceof IdSet` selects the "is a set" branch, + * `$type === $idMapAny` selects the "is a map" branch, so both ternaries below flip. Compare + * against `$idMapAny` (the interned tag), never `$idMap(attrs)` - that builds a fresh `s.$custom` + * per call, so `===` against it is always false. + * + * The reason is soundness, not speed: `instanceof` walks the prototype chain and is per-install, so + * an `IdSet` from a duplicate yjs install falls through to the IdMap branch and we silently build an + * IdMap holding IdRanges. `$type` is a single inline-cached load and stays sound across installs - + * the same reason `$renderer` uses `$type` over `$instanceOf`. Not a perf win either way: this runs + * twice per call, outside both loops. + * + * Applies equally to `_intersectSets`. + * + * Remove all ranges from `exclude` from `ds`. The result is a fresh IdSet containing all ranges from `idSet` that are not + * in `exclude`. + * + * @template {IdSet | IdMap} Set + * @param {Set} set + * @param {IdSet | IdMap} exclude + * @return {Set} + */ +export const _diffSet = (set, exclude) => { + /** + * @type {Set} + */ + const res = /** @type {any } */ (set instanceof IdSet ? new IdSet() : new IdMap()) + const Ranges = set instanceof IdSet ? IdRanges : AttrRanges + set.clients.forEach((_setRanges, client) => { + /** + * @type {Array} + */ + let resRanges = [] + const _excludedRanges = exclude.clients.get(client) + const setRanges = _setRanges.getIds() + if (_excludedRanges == null) { + resRanges = setRanges.slice() + } else { + const excludedRanges = _excludedRanges.getIds() + let i = 0; let j = 0 + let currRange = setRanges[0] + while (i < setRanges.length && j < excludedRanges.length) { + const e = excludedRanges[j] + if (currRange.clock + currRange.len <= e.clock) { // no overlapping, use next range item + if (currRange.len > 0) resRanges.push(currRange) + currRange = setRanges[++i] + } else if (e.clock + e.len <= currRange.clock) { // no overlapping, use next excluded item + j++ + } else if (e.clock <= currRange.clock) { // exclude laps into range (we already know that the ranges somehow collide) + const newClock = e.clock + e.len + const newLen = currRange.clock + currRange.len - newClock + if (newLen > 0) { + currRange = currRange.copyWith(newClock, newLen) + j++ + } else { + // this item is completely overwritten. len=0. We can jump to the next range + currRange = setRanges[++i] + } + } else { // currRange.clock < e.clock -- range laps into exclude => adjust len + // beginning can't be empty, add it to the result + const nextLen = e.clock - currRange.clock + resRanges.push(currRange.copyWith(currRange.clock, nextLen)) + // retain the remaining length after exclude in currRange + currRange = currRange.copyWith(currRange.clock + e.len + nextLen, math.max(currRange.len - e.len - nextLen, 0)) + if (currRange.len === 0) currRange = setRanges[++i] + else j++ + } + } + if (currRange != null) { + resRanges.push(currRange) + } + i++ + while (i < setRanges.length) { + resRanges.push(setRanges[i++]) + } + } + // @ts-ignore + if (resRanges.length > 0) res.clients.set(client, /** @type {any} */ (new Ranges(resRanges))) + }) + return res +} + +/** + * Remove all ranges from `exclude` from `idSet`. The result is a fresh IdSet containing all ranges from `idSet` that are not + * in `exclude`. + * + * @type {(idSet: IdSet, exclude: IdSet|IdMap) => IdSet} + */ +export const diffIdSet = _diffSet + +/** + * @todo dispatch on `setA.$type === $idMapAny` rather than `setA instanceof IdSet` - see the note + * on `_diffSet`. The polarity inverts. + * + * @template {IdSet | IdMap} SetA + * @template {IdSet | IdMap} SetB + * @param {SetA} setA + * @param {SetB} setB + * @return {SetA extends IdMap ? (SetB extends IdMap ? IdMap : IdMap) : IdSet} + */ +export const _intersectSets = (setA, setB) => { + /** + * @type {IdMap | IdSet} + */ + const res = /** @type {any } */ (setA instanceof IdSet ? new IdSet() : new IdMap()) + const Ranges = setA instanceof IdSet ? IdRanges : AttrRanges + setA.clients.forEach((_aRanges, client) => { + /** + * @type {Array} + */ + const resRanges = [] + const _bRanges = setB.clients.get(client) + const aRanges = _aRanges.getIds() + if (_bRanges != null) { + const bRanges = _bRanges.getIds() + for (let a = 0, b = 0; a < aRanges.length && b < bRanges.length;) { + const aRange = aRanges[a] + const bRange = bRanges[b] + // construct overlap + const clock = math.max(aRange.clock, bRange.clock) + const len = math.min(aRange.len - (clock - aRange.clock), bRange.len - (clock - bRange.clock)) + if (len > 0) { + resRanges.push(aRange instanceof AttrRange + ? new AttrRange(clock, len, /** @type {Array} */ (aRange.attrs).concat(bRange.attrs)) + : new IdRange(clock, len) + ) + } + if (aRange.clock + aRange.len < bRange.clock + bRange.len) { + a++ + } else { + b++ + } + } + } + // @ts-ignore + if (resRanges.length > 0) res.clients.set(client, /** @type {any} */ (new Ranges(resRanges))) + }) + return /** @type {any} */ (res) +} + +export const intersectSets = _intersectSets + +export const createIdSet = () => new IdSet() + +/** + * @param {StructStore} ss + * @return {IdSet} + * + * @private + * @function + */ +export const createDeleteSetFromStructStore = ss => { + const ds = createIdSet() + ss.clients.forEach((structs, client) => { + /** + * @type {Array} + */ + const dsitems = [] + for (let i = 0; i < structs.length; i++) { + const struct = structs[i] + if (struct.deleted) { + const clock = struct.id.clock + let len = struct.length + if (i + 1 < structs.length) { + for (let next = structs[i + 1]; i + 1 < structs.length && next.deleted; next = structs[++i + 1]) { + len += next.length + } + } + dsitems.push(new IdRange(clock, len)) + } + } + if (dsitems.length > 0) { + ds.clients.set(client, new IdRanges(dsitems)) + } + }) + return ds +} + +/** + * @param {Array} structs + * @param {boolean} filterDeleted + * + */ +export const _createInsertSliceFromStructs = (structs, filterDeleted) => { + /** + * @type {Array} + */ + const iditems = [] + for (let i = 0; i < structs.length; i++) { + const struct = structs[i] + if (!(filterDeleted && struct.deleted)) { + const clock = struct.id.clock + let len = struct.length + if (i + 1 < structs.length) { + // eslint-disable-next-line + for (let next = structs[i + 1]; i + 1 < structs.length && !(filterDeleted && next.deleted); next = structs[++i + 1]) { + len += next.length + } + } + iditems.push(new IdRange(clock, len)) + } + } + return iditems +} + +/** + * @param {StructStore} ss + * @param {boolean} filterDeleted + */ +export const createInsertSetFromStructStore = (ss, filterDeleted) => { + const idset = createIdSet() + ss.clients.forEach((structs, client) => { + const iditems = _createInsertSliceFromStructs(structs, filterDeleted) + if (iditems.length !== 0) { + idset.clients.set(client, new IdRanges(iditems)) + } + }) + return idset +} + +/** + * @param {IdSetEncoderV1 | IdSetEncoderV2} encoder + * @param {IdSet} idSet + * + * @private + * @function + */ +export const writeIdSet = (encoder, idSet) => { + encoding.writeVarUint(encoder.restEncoder, idSet.clients.size) + // Ensure that the delete set is written in a deterministic order + array.from(idSet.clients.entries()) + .sort((a, b) => b[0] - a[0]) + .forEach(([client, _idRanges]) => { + const idRanges = _idRanges.getIds() + encoder.resetIdSetCurVal() + encoding.writeVarUint(encoder.restEncoder, client) + const len = idRanges.length + encoding.writeVarUint(encoder.restEncoder, len) + for (let i = 0; i < len; i++) { + const item = idRanges[i] + encoder.writeIdSetClock(item.clock) + encoder.writeIdSetLen(item.len) + } + }) +} + +/** + * @param {IdSetDecoderV1 | IdSetDecoderV2} decoder + * @return {IdSet} + * + * @private + * @function + */ +export const readIdSet = decoder => { + const ds = new IdSet() + const numClients = decoding.readVarUint(decoder.restDecoder) + for (let i = 0; i < numClients; i++) { + decoder.resetDsCurVal() + const client = decoding.readVarUint(decoder.restDecoder) + const numberOfDeletes = decoding.readVarUint(decoder.restDecoder) + if (numberOfDeletes > 0) { + /** + * @type {Array} + */ + const dsRanges = [] + for (let i = 0; i < numberOfDeletes; i++) { + dsRanges.push(new IdRange(decoder.readDsClock(), decoder.readDsLen())) + } + ds.clients.set(client, new IdRanges(dsRanges)) + } + } + return ds +} + +/** + * @param {IdSet} idSet + * @return {Uint8Array} + */ +export const encodeIdSet = idSet => { + const encoder = new IdSetEncoderV2() + writeIdSet(encoder, idSet) + return encoder.toUint8Array() +} + +/** + * @param {Uint8Array} data + * @return {IdSet} + */ +export const decodeIdSet = data => readIdSet(new IdSetDecoderV2(decoding.createDecoder(data))) + +/** + * @todo YDecoder also contains references to String and other Decoders. Would make sense to exchange YDecoder.toUint8Array for YDecoder.DsToUint8Array().. + */ + +/** + * @param {IdSetDecoderV1 | IdSetDecoderV2} decoder + * @param {Transaction} transaction + * @param {StructStore} store + * @return {Uint8Array|null} Returns a v2 update containing all deletes that couldn't be applied yet; or null if all deletes were applied successfully. + * + * @private + * @function + */ +export const readAndApplyDeleteSet = (decoder, transaction, store) => { + const unappliedDS = new IdSet() + const numClients = decoding.readVarUint(decoder.restDecoder) + for (let i = 0; i < numClients; i++) { + decoder.resetDsCurVal() + const client = decoding.readVarUint(decoder.restDecoder) + const numberOfDeletes = decoding.readVarUint(decoder.restDecoder) + const structs = store.clients.get(client) || [] + const state = store.getClock(client) + for (let i = 0; i < numberOfDeletes; i++) { + const clock = decoder.readDsClock() + const clockEnd = clock + decoder.readDsLen() + if (clock < state) { + if (state < clockEnd) { + unappliedDS.add(client, state, clockEnd - state) + } + let index = findIndexSS(structs, clock) + /** + * We can ignore the case of GC and Delete structs, because we are going to skip them + * @type {Item | GC | Skip} + */ + let struct = structs[index] + // split the first item if necessary + if (!struct.deleted && struct.id.clock < clock && struct.isItem) { + // increment index, we now want to use the next struct + structs.splice(++index, 0, /** @type {Item} */ (struct).split(transaction, clock - struct.id.clock)) + } + while (index < structs.length) { + // @ts-ignore + struct = structs[index++] + if (struct.id.clock < clockEnd) { + if (!struct.deleted) { + if (struct.isItem) { + if (clockEnd < struct.id.clock + struct.length) { + structs.splice(index, 0, /** @type {Item} */ (struct).split(transaction, clockEnd - struct.id.clock)) + } + struct.delete(transaction) + } else { // is a Skip - add range to unappliedDS + const c = math.max(struct.id.clock, clock) + unappliedDS.add(client, c, math.min(struct.length, clockEnd - c)) + } + } + } else { + break + } + } + } else { + unappliedDS.add(client, clock, clockEnd - clock) + } + } + } + if (unappliedDS.clients.size > 0) { + const ds = new UpdateEncoderV2() + encoding.writeVarUint(ds.restEncoder, 0) // encode 0 structs + writeIdSet(ds, unappliedDS) + return ds.toUint8Array() + } + return null +} + +/** + * @template Attrs + */ +export class AttrRange { + /** + * @param {number} clock + * @param {number} len + * @param {Array>} attrs + */ + constructor (clock, len, attrs) { + /** + * @readonly + */ + this.clock = clock + /** + * @readonly + */ + this.len = len + /** + * @readonly + */ + this.attrs = attrs + } + + /** + * @param {number} clock + * @param {number} len + */ + copyWith (clock, len) { + return new AttrRange(clock, len, this.attrs) + } +} + +/** + * @todo rename this to `Attribute` + * @template V + */ +export class ContentAttribute { + /** + * @param {string} name + * @param {V} val + */ + constructor (name, val) { + this.name = name + this.val = val + } + + hash () { + const encoder = encoding.createEncoder() + encoding.writeVarString(encoder, this.name) + encoding.writeAny(encoder, /** @type {any} */ (this.val)) + return buf.toBase64(rabin.fingerprint(rabin.StandardIrreducible128, encoding.toUint8Array(encoder))) + } +} + +/** + * @template V + * @param {string} name + * @param {V} val + * @return {ContentAttribute} + */ +export const createContentAttribute = (name, val) => new ContentAttribute(name, val) + +/** + * @template Attrs + * @typedef {{ clock: number, len: number, attrs: Array>? }} MaybeAttrRange + */ + +/** + * @template Attrs + * + * @param {number} clock + * @param {number} len + * @param {Array>?} attrs + * @return {MaybeAttrRange} + */ +export const createMaybeAttrRange = (clock, len, attrs) => new AttrRange(clock, len, /** @type {any} */ (attrs)) + +/** + * @template T + * @param {Array} a + * @param {Array} b + */ +const idmapAttrRangeJoin = (a, b) => a.concat(b.filter(attr => !idmapAttrsHas(a, attr))) + +/** + * Whenever this is instantiated, it must receive a fresh array of ops, not something copied. + * + * @template Attrs + */ +export class AttrRanges { + /** + * @param {Array>} ids + */ + constructor (ids) { + this.sorted = false + /** + * @private + */ + this._ids = ids + } + + copy () { + const cpy = new AttrRanges(this._ids.slice()) + cpy.sorted = this.sorted + return cpy + } + + /** + * @param {number} clock + * @param {number} length + * @param {Array>} attrs + */ + add (clock, length, attrs) { + if (length === 0) return + this.sorted = false + this._ids.push(new AttrRange(clock, length, attrs)) + } + + /** + * Return the list of id ranges, sorted and merged. + */ + getIds () { + const ids = this._ids + if (!this.sorted) { + this.sorted = true + ids.sort((a, b) => a.clock - b.clock) + /** + * algorithm thoughts: + * - sort (by clock AND by length), bigger length is to the right (or not, we can't make + * assumptions abouth length after long length has been split) + * -- maybe better: sort by clock+length. Then split items from right to left. This way, items are always + * in the right order. But I also need to swap if left items is smaller after split + * --- thought: there is no way to go around swapping. Unless, for each item from left to + * right, when I have to split because one of the look-ahead items is overlapping, i split + * it and merge the attributes into the following ones (that I also need to split). Best is + * probably left to right with lookahead. + * - left to right, split overlapping items so that we can make the assumption that either an + * item is overlapping with the next 1-on-1 or it is not overlapping at all (when splitting, + * we can already incorporate the attributes) + * -- better: for each item, go left to right and add own attributes to overlapping items. + * Split them if necessary. After split, i must insert the retainer at a valid position. + * - merge items if neighbor has same attributes + */ + for (let i = 0; i < ids.length - 1;) { + const range = ids[i] + const nextRange = ids[i + 1] + // find out how to split range. it must match with next range. + // 1) we have space. Split if necessary. + // 2) concat attributes in range to the next range. Split range and splice the remainder at + // the correct position. + if (range.clock < nextRange.clock) { // might need to split range + if (range.clock + range.len > nextRange.clock) { + // is overlapping + const diff = nextRange.clock - range.clock + ids[i] = new AttrRange(range.clock, diff, range.attrs) + ids.splice(i + 1, 0, new AttrRange(nextRange.clock, range.len - diff, range.attrs)) + } + i++ + continue + } + // now we know that range.clock === nextRange.clock + // merge range with nextRange + const largerRange = range.len > nextRange.len ? range : nextRange + const smallerLen = range.len < nextRange.len ? range.len : nextRange.len + ids[i] = new AttrRange(range.clock, smallerLen, idmapAttrRangeJoin(range.attrs, nextRange.attrs)) + if (range.len === nextRange.len) { + ids.splice(i + 1, 1) + } else { + ids[i + 1] = new AttrRange(range.clock + smallerLen, largerRange.len - smallerLen, largerRange.attrs) + array.bubblesortItem(ids, i + 1, (a, b) => a.clock - b.clock) + } + if (smallerLen === 0) i++ + } + while (ids.length > 0 && ids[0].len === 0) { + ids.splice(0, 1) + } + // merge items without filtering or splicing the array. + // i is the current pointer + // j refers to the current insert position for the pointed item + // try to merge dels[i] into dels[j-1] or set dels[j]=dels[i] + let i, j + for (i = 1, j = 1; i < ids.length; i++) { + const left = ids[j - 1] + const right = ids[i] + if (left.clock + left.len === right.clock && idmapAttrsEqual(left.attrs, right.attrs)) { + ids[j - 1] = new AttrRange(left.clock, left.len + right.len, left.attrs) + } else if (right.len !== 0) { + if (j < i) { + ids[j] = right + } + j++ + } + } + ids.length = ids.length === 0 ? 0 : (ids[j - 1].len === 0 ? j - 1 : j) + } + return ids + } + + /** + * Test a predicate against every attribute held by these ranges. + * + * Unlike {@link AttrRanges#getIds} this does **not** sort/merge - i.e. it does not mutate its + * receiver, which matters because it backs {@link $idMap}`.check`. The *set* of attributes is + * invariant under sorting/merging (merging only ever concatenates existing attr arrays), so this + * observes exactly the attributes that `getIds()` would expose. + * + * @param {(attr:ContentAttribute) => boolean} f + * @return {boolean} + */ + everyAttr (f) { + const ids = this._ids + for (let i = 0; i < ids.length; i++) { + const attrs = ids[i].attrs + // only `createMaybeAttrRange` yields null attrs, never `clients` - defensive + if (attrs == null) continue + for (let j = 0; j < attrs.length; j++) { + if (!f(attrs[j])) return false + } + } + return true + } +} + +/** + * @template Attrs + */ +export class IdMap { + constructor () { + /** + * @type {Map>} + */ + this.clients = new Map() + /** + * @type {Map>} + */ + this.attrsH = new Map() + /** + * @type {Set>} + */ + this.attrs = new Set() + } + + /** + * @param {(attrRange:AttrRange, client:number) => void} f + */ + forEach (f) { + this.clients.forEach((ranges, client) => { + ranges.getIds().forEach((range) => { + f(range, client) + }) + }) + } + + isEmpty () { + return this.clients.size === 0 + } + + /** + * @param {ID} id + * @return {boolean} + */ + hasId (id) { + return this.has(id.client, id.clock) + } + + /** + * @param {number} client + * @param {number} clock + * @return {boolean} + */ + has (client, clock) { + const dr = this.clients.get(client) + if (dr) { + return findIndexInIdRanges(dr.getIds(), clock) !== null + } + return false + } + + /** + * Total number of ids in `[clock, clock+len)` of `client` contained in this map (i.e. carrying + * an attribution, incl. an empty `[]`). Allocation-free (binary search + linear scan). + * Equivalent to `slice(client, clock, len).reduce((s, r) => r.attrs != null ? s + r.len : s, 0)` + * without allocating. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {number} + */ + coveredLength (client, clock, len) { + const dr = this.clients.get(client) + let covered = 0 + if (dr) { + const ranges = dr.getIds() + const end = clock + len + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + for (; index < ranges.length; index++) { + const r = ranges[index] + if (r.clock >= end) break + const from = r.clock < clock ? clock : r.clock + const rEnd = r.clock + r.len + const to = rEnd > end ? end : rEnd + covered += to - from + } + } + } + return covered + } + + /** + * Return attributions for a slice of ids. + * + * @param {ID} id + * @param {number} len + * @return {Array>} + */ + sliceId (id, len) { + return this.slice(id.client, id.clock, len) + } + + /** + * Return attributions for a slice of ids. + * + * @param {number} client + * @param {number} clock + * @param {number} len + * @return {Array>} + */ + slice (client, clock, len) { + const dr = this.clients.get(client) + /** + * @type {Array>} + */ + const res = [] + if (dr) { + /** + * @type {Array>} + */ + const ranges = dr.getIds() + let index = findRangeStartInIdRanges(ranges, clock) + if (index !== null) { + let prev = null + while (index < ranges.length) { + let r = ranges[index] + if (r.clock < clock) { + r = new AttrRange(clock, r.len - (clock - r.clock), r.attrs) + } + if (r.clock + r.len > clock + len) { + r = new AttrRange(r.clock, clock + len - r.clock, r.attrs) + } + if (r.len <= 0) break + const prevEnd = prev != null ? prev.clock + prev.len : clock + if (prevEnd < r.clock) { + res.push(createMaybeAttrRange(prevEnd, r.clock - prevEnd, null)) + } + prev = r + res.push(r) + index++ + } + } + } + if (res.length > 0) { + const last = res[res.length - 1] + const end = last.clock + last.len + if (end < clock + len) { + res.push(createMaybeAttrRange(end, clock + len - end, null)) + } + } else { + res.push(createMaybeAttrRange(clock, len, null)) + } + return res + } + + /** + * @param {number} client + * @param {number} clock + * @param {number} len + * @param {Array>} attrs + */ + add (client, clock, len, attrs) { + if (len === 0) return + attrs = _ensureAttrs(this, attrs) + const ranges = this.clients.get(client) + if (ranges == null) { + this.clients.set(client, new AttrRanges([new AttrRange(clock, len, attrs)])) + } else { + ranges.add(clock, len, attrs) + } + } + + /** + * @param {number} client + * @param {number} clock + * @param {number} len + */ + delete (client, clock, len) { + _deleteRangeFromIdSet(this, client, clock, len) + } +} + +/** + * @template T + * @param {Array} attrs + * @param {T} attr + * + */ +const idmapAttrsHas = (attrs, attr) => attrs.find(a => a === attr) + +/** + * @template T + * @param {Array} a + * @param {Array} b + */ +export const idmapAttrsEqual = (a, b) => a.length === b.length && a.every(v => idmapAttrsHas(b, v)) + +/** + * Merge multiple idmaps. Ensures that there are no redundant attribution definitions (two + * Attributions that describe the same thing). + * + * @template T + * @param {Array>} ams + * @return {IdMap} A fresh IdSet + */ +export const mergeIdMaps = ams => { + /** + * Maps attribution to the attribution of the merged idmap. + * + * @type {Map,ContentAttribute>} + */ + const attrMapper = new Map() + const merged = createIdMap() + for (let amsI = 0; amsI < ams.length; amsI++) { + ams[amsI].clients.forEach((rangesLeft, client) => { + if (!merged.clients.has(client)) { + // Write all missing keys from current set and all following. + // If merged already contains `client` current ds has already been added. + let ids = rangesLeft.getIds().slice() + for (let i = amsI + 1; i < ams.length; i++) { + const nextIds = ams[i].clients.get(client) + if (nextIds) { + array.appendTo(ids, nextIds.getIds()) + } + } + ids = ids.map(id => new AttrRange(id.clock, id.len, id.attrs.map(attr => + map.setIfUndefined(attrMapper, attr, () => + _ensureAttrs(merged, [attr])[0] + ) + ))) + merged.clients.set(client, new AttrRanges(ids)) + } + }) + } + return merged +} + +/** + * @param {IdSet} idset + * @param {Array>} attrs + */ +export const createIdMapFromIdSet = (idset, attrs) => { + const idmap = createIdMap() + // map attrs to idmap + attrs = _ensureAttrs(idmap, attrs) + // filter out duplicates + /** + * @type {Array>} + */ + const checkedAttrs = [] + attrs.forEach(attr => { + if (!idmapAttrsHas(checkedAttrs, attr)) { + checkedAttrs.push(attr) + } + }) + idset.clients.forEach((ranges, client) => { + const attrRanges = new AttrRanges(ranges.getIds().map(range => new AttrRange(range.clock, range.len, checkedAttrs))) + attrRanges.sorted = true // is sorted because idset is sorted + idmap.clients.set(client, attrRanges) + }) + return idmap +} + +/** + * Create an IdSet from an IdMap by stripping the attributes. + * + * @param {IdMap} idmap + * @return {IdSet} + */ +export const createIdSetFromIdMap = idmap => { + const idset = createIdSet() + idmap.clients.forEach((ranges, client) => { + const idRanges = new IdRanges([]) + ranges.getIds().forEach(range => idRanges.add(range.clock, range.len)) + idset.clients.set(client, idRanges) + }) + return idset +} + +/** + * Efficiently encodes IdMap to a binary form. Ensures that information is de-duplicated when + * written. Attribute.names are referenced by id. Attributes themselfs are also referenced by id. + * + * @template Attr + * @param {IdSetEncoderV1 | IdSetEncoderV2} encoder + * @param {IdMap} idmap + * + * @private + * @function + */ +export const writeIdMap = (encoder, idmap) => { + encoding.writeVarUint(encoder.restEncoder, idmap.clients.size) + let lastWrittenClientId = 0 + /** + * @type {Map, number>} + */ + const visitedAttributions = map.create() + /** + * @type {Map} + */ + const visitedAttrNames = map.create() + // Ensure that the ids are written in a deterministic order (smaller clientids first) + array.from(idmap.clients.entries()) + .sort((a, b) => a[0] - b[0]) + .forEach(([client, _idRanges]) => { + const attrRanges = _idRanges.getIds() + encoder.resetIdSetCurVal() + const diff = client - lastWrittenClientId + encoding.writeVarUint(encoder.restEncoder, diff) + lastWrittenClientId = client + const len = attrRanges.length + encoding.writeVarUint(encoder.restEncoder, len) + for (let i = 0; i < len; i++) { + const item = attrRanges[i] + const attrs = item.attrs + const attrLen = attrs.length + encoder.writeIdSetClock(item.clock) + encoder.writeIdSetLen(item.len) + encoding.writeVarUint(encoder.restEncoder, attrLen) + for (let j = 0; j < attrLen; j++) { + const attr = attrs[j] + const attrId = visitedAttributions.get(attr) + if (attrId != null) { + encoding.writeVarUint(encoder.restEncoder, attrId) + } else { + const newAttrId = visitedAttributions.size + visitedAttributions.set(attr, newAttrId) + encoding.writeVarUint(encoder.restEncoder, newAttrId) + const attrNameId = visitedAttrNames.get(attr.name) + // write attr.name + if (attrNameId != null) { + encoding.writeVarUint(encoder.restEncoder, attrNameId) + } else { + const newAttrNameId = visitedAttrNames.size + encoding.writeVarUint(encoder.restEncoder, newAttrNameId) + encoding.writeVarString(encoder.restEncoder, attr.name) + visitedAttrNames.set(attr.name, newAttrNameId) + } + encoding.writeAny(encoder.restEncoder, /** @type {any} */ (attr.val)) + } + } + } + }) +} + +/** + * @param {IdMap} idmap + */ +export const encodeIdMap = idmap => { + const encoder = new IdSetEncoderV2() + writeIdMap(encoder, idmap) + return encoder.toUint8Array() +} + +/** + * @param {IdSetDecoderV1 | IdSetDecoderV2} decoder + * @return {IdMap} + * + * @private + * @function + */ +export const readIdMap = decoder => { + const idmap = new IdMap() + const numClients = decoding.readVarUint(decoder.restDecoder) + /** + * @type {Array>} + */ + const visitedAttributions = [] + /** + * @type {Array} + */ + const visitedAttrNames = [] + let lastClientId = 0 + for (let i = 0; i < numClients; i++) { + decoder.resetDsCurVal() + const client = lastClientId + decoding.readVarUint(decoder.restDecoder) + lastClientId = client + const numberOfDeletes = decoding.readVarUint(decoder.restDecoder) + /** + * @type {Array>} + */ + const attrRanges = [] + for (let i = 0; i < numberOfDeletes; i++) { + const rangeClock = decoder.readDsClock() + const rangeLen = decoder.readDsLen() + /** + * @type {Array>} + */ + const attrs = [] + const attrsLen = decoding.readVarUint(decoder.restDecoder) + for (let j = 0; j < attrsLen; j++) { + const attrId = decoding.readVarUint(decoder.restDecoder) + if (attrId >= visitedAttributions.length) { + // attrId not known yet + const attrNameId = decoding.readVarUint(decoder.restDecoder) + if (attrNameId >= visitedAttrNames.length) { + visitedAttrNames.push(decoding.readVarString(decoder.restDecoder)) + } + visitedAttributions.push(new ContentAttribute(visitedAttrNames[attrNameId], decoding.readAny(decoder.restDecoder))) + } + attrs.push(visitedAttributions[attrId]) + } + attrRanges.push(new AttrRange(rangeClock, rangeLen, attrs)) + } + idmap.clients.set(client, new AttrRanges(attrRanges)) + } + visitedAttributions.forEach(attr => { + idmap.attrs.add(attr) + idmap.attrsH.set(attr.hash(), attr) + }) + return idmap +} + +/** + * @param {Uint8Array} data + * @return {IdMap} + */ +export const decodeIdMap = data => readIdMap(new IdSetDecoderV2(decoding.createDecoder(data))) + +/** + * @template Attrs + * @param {IdMap} idmap + * @param {Array>} attrs + * @return {Array>} + */ +const _ensureAttrs = (idmap, attrs) => attrs.map(attr => + idmap.attrs.has(attr) + ? attr + : map.setIfUndefined(idmap.attrsH, attr.hash(), () => { + idmap.attrs.add(attr) + return attr + })) + +export const createIdMap = () => new IdMap() + +/** + * Remove all ranges from `exclude` from `ds`. The result is a fresh IdMap containing all ranges from `idSet` that are not + * in `exclude`. + * + * @todo this should be called "excludeIdMap" + * + * @todo the `instanceof IdSet` dispatch this relies on lives in `_diffSet` - see the `$idMapAny` + * note there. Separately, the `attrs`/`attrsH` aliasing below leaves a *stale* index on an emptied + * result. {@link $idMap} is unaffected: it reads the ranges, which are authoritative. + * + * @template {IdMap} ISet + * @param {ISet} set + * @param {IdSet | IdMap} exclude + * @return {ISet} + */ +export const diffIdMap = (set, exclude) => { + const diffed = _diffSet(set, exclude) + diffed.attrs = set.attrs + diffed.attrsH = set.attrsH + return diffed +} + +export const intersectMaps = _intersectSets + +/** + * Filter attributes in an IdMap based on a predicate function. + * Returns a new IdMap containing idranges that match the predicate. + * + * @template Attrs + * @param {IdMap} idmap + * @param {(attr: Array>) => boolean} predicate + * @return {IdMap} + */ +export const filterIdMap = (idmap, predicate) => { + const filtered = createIdMap() + idmap.clients.forEach((ranges, client) => { + /** + * @type {Array>} + */ + const attrRanges = [] + ranges.getIds().forEach((range) => { + if (predicate(range.attrs)) { + const rangeCpy = range.copyWith(range.clock, range.len) + attrRanges.push(rangeCpy) + rangeCpy.attrs.forEach(attr => { + filtered.attrs.add(attr) + filtered.attrsH.set(attr.hash(), attr) + }) + } + }) + if (attrRanges.length > 0) { + filtered.clients.set(client, new AttrRanges(attrRanges)) + } + }) + return filtered +} + +/** + * Schema of an {@link IdSet}. + * + * Nominal: `check` is a single identity compare against the globally interned `y:idSet` tag, so it + * stays sound across duplicate yjs installations (unlike `instanceof`) - the same reason + * {@link $renderer} uses `$type`. + */ +export const $idSet = IdSet.prototype.$type = s.$type('y:idSet', IdSet) + +/** + * Schema of an {@link IdMap} with any mapped-value type. This is where the nominal `y:idMap` tag + * lives - {@link $idMap} builds on it. Mirrors lib0's `$deltaAny` / `$delta` split. + */ +export const $idMapAny = /** @type {s.Schema>} */ (IdMap.prototype.$type = s.$type('y:idMap', IdMap)) + +/** + * Schema of an {@link IdMap} whose mapped values ({@link ContentAttribute#val}) all match `$attrs`. + * + * True iff the value carries the nominal `y:idMap` tag **and** every attribute reachable through + * `clients` satisfies `$attrs`. Those ranges are the authoritative content - exactly what + * {@link writeIdMap} serializes. An empty map holds no values, so it satisfies every `$attrs`. + * + * This deliberately ignores `attrs` / `attrsH`: that pair is a hash-interning cache for `===` + * comparison (see `_ensureAttrs`), and it drifts from the ranges in *both* directions today - + * {@link diffIdMap} aliases a stale index onto an emptied result, while {@link intersectMaps} and + * {@link insertIntoIdMap} never rebuild it, so most real maps carry an empty index while holding + * values. An index-based check would therefore validate nothing for the common case. + * + * Like every container schema this is a *snapshot*: an `IdMap` is mutable, so a later `add()` can + * invalidate a previously-passing check. + * + * Allocates a fresh schema per call - hoist it rather than calling it in a hot loop. For a cheap + * nominal gate use {@link $idMapAny} (one identity compare). + * + * @template Attrs + * @param {s.Schema} $attrs - schema of the mapped values + * @return {s.Schema>} + */ +export const $idMap = $attrs => s.$custom(o => { + if (!$idMapAny.check(o)) return false + for (const ranges of o.clients.values()) { + if (!ranges.everyAttr(attr => $attrs.check(attr.val))) return false + } + return true +}) diff --git a/src/utils/isParentOf.js b/src/utils/isParentOf.js new file mode 100644 index 0000000..9f499d5 --- /dev/null +++ b/src/utils/isParentOf.js @@ -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 +} diff --git a/src/utils/logging.js b/src/utils/logging.js new file mode 100644 index 0000000..1987ae3 --- /dev/null +++ b/src/utils/logging.js @@ -0,0 +1,17 @@ +/** + * Convenient helper to log type information. + * + * Do not use in productive systems as the output can be immense! + * + * @param {YNode} 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)) +} diff --git a/src/utils/meta.js b/src/utils/meta.js new file mode 100644 index 0000000..89e4478 --- /dev/null +++ b/src/utils/meta.js @@ -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} contents + */ +export const mergeContentMaps = contents => createContentMap( + mergeIdMaps(contents.map(c => c.inserts)), + mergeIdMaps(contents.map(c => c.deletes)) +) + +/** + * @param {Array} contents + */ +export const mergeContentIds = contents => createContentIds( + mergeIdSets(contents.map(c => c.inserts)), + mergeIdSets(contents.map(c => c.deletes)) +) + +/** + * @param {IdMap} [inserts] + * @param {IdMap} [deletes] + * @return {ContentMap} + */ +export const createContentMap = (inserts = createIdMap(), deletes = createIdMap()) => ({ inserts, deletes }) + +/** + * @param {ContentIds} contentIds + * @param {Array>} insertAttrs + * @param {Array>} [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} 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} buf + */ +export const decodeContentMap = buf => readContentMap(new IdSetDecoderV2(decoding.createDecoder(buf))) + +/** + * @todo filter by array of content instead + * @param {ContentMap} contentMap + * @param {(c:Array>)=>boolean} insertPredicate + * @param {(c:Array>)=>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} + */ +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`. + * + * @type {s.Schema} + */ +export const $contentMap = s.$object({ inserts: $idMapAny, deletes: $idMapAny }) diff --git a/src/utils/position-helpers.js b/src/utils/position-helpers.js new file mode 100644 index 0000000..7257f03 --- /dev/null +++ b/src/utils/position-helpers.js @@ -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|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|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} 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|Doc} root + * @param {Array} positions + * @param {{ renderer?: AbstractRenderer|null }} [opts] + * @return {Array} + */ +export const createRelativePositionsFromDeltaPositions = (root, positions, opts = {}) => { + const renderer = resolveRenderer(root, opts) + /** + * @type {Array} + */ + const results = new Array(positions.length).fill(null) + /** + * @typedef {{ posIdx: number, pos: DeltaPosition, depth: number }} Task + */ + /** + * @type {Map, Array>} + */ + 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} */ ([])).push({ posIdx: i, pos, depth: 1 }) + } else { + map.setIfUndefined(layer, root, () => /** @type {Array} */ ([])).push({ posIdx: i, pos, depth: 0 }) + } + } + while (layer.size > 0) { + /** + * @type {Map, Array>} + */ + 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} */ ([])).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} */ ([])).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} 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|Doc} root + * @param {Array} rposs - `null` entries (e.g. from the forward transform) map to `null` + * @param {{ renderer?: AbstractRenderer|null, followUndoneDeletions?: boolean }} [opts] + * @return {Array} + */ +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} */ (root).doc + /** + * @type {Array} + */ + const results = new Array(rposs.length).fill(null) + if (doc === null) return results + const store = doc.store + /** + * @param {YNode} node + * @return {Array|null} + */ + const pathFromRoot = node => { + if (isDocRoot) { + if (node.doc !== doc) return null + let top = node + while (top._item !== null) { + top = /** @type {YNode} */ (top._item.parent) + } + return [findRootTypeKey(top), ...getPathTo(top, node, renderer)] + } + if (node !== root && (node._item === null || !isParentOf(/** @type {YNode} */ (root), node._item))) return null + return getPathTo(/** @type {YNode} */ (root), node, renderer) + } + /** + * @type {Map, Array|null>} + */ + const pathCache = new Map() + /** + * @type {Map, number>} + */ + const renderedLenCache = new Map() + for (let i = 0; i < rposs.length; i++) { + const rpos = rposs[i] + if (rpos == null) continue + /** + * @type {YNode} + */ + 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} */ (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} */ (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|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|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] diff --git a/src/utils/renderer-helpers.js b/src/utils/renderer-helpers.js new file mode 100644 index 0000000..320e035 --- /dev/null +++ b/src/utils/renderer-helpers.js @@ -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} Attribution + */ + +/** + * @template T + */ +export class AttributedContent { + /** + * @param {AbstractContent} content + * @param {number} clock + * @param {boolean} deleted + * @param {Array> | 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>} _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) diff --git a/src/utils/schemas.js b/src/utils/schemas.js new file mode 100644 index 0000000..f4fe2b5 --- /dev/null +++ b/src/utils/schemas.js @@ -0,0 +1,3 @@ +import * as s from 'lib0/schema' + +export const $doc = /** @type {s.Schema} */ (s.$type('y:doc', null)) diff --git a/src/utils/transaction-helpers.js b/src/utils/transaction-helpers.js new file mode 100644 index 0000000..d5ffeaa --- /dev/null +++ b/src/utils/transaction-helpers.js @@ -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} 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} 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} */ (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} */ (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} 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} 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} 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} */ (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} */ (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} 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} startFormats + * @param {Map} 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} + */ + 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 +} diff --git a/src/utils/updates.js b/src/utils/updates.js new file mode 100644 index 0000000..bcb81da --- /dev/null +++ b/src/utils/updates.js @@ -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} + */ +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} + */ +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} + */ +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} + */ +export const intersectUpdateWithContentIds = (update, contentIds) => + intersectUpdateWithContentIdsV2(update, contentIds, UpdateDecoderV1, UpdateEncoderV1) diff --git a/src/ynode.js b/src/ynode.js new file mode 100644 index 0000000..d311ffb --- /dev/null +++ b/src/ynode.js @@ -0,0 +1,2824 @@ +import * as binary from 'lib0/binary' +import * as array from 'lib0/array' +import * as delta from 'lib0/delta' +import * as error from 'lib0/error' +import * as iterator from 'lib0/iterator' +import * as log from 'lib0/logging' +import * as map from 'lib0/map' +import * as set from 'lib0/set' +import * as math from 'lib0/math' +import * as object from 'lib0/object' +import * as s from 'lib0/schema' +import * as traits from 'lib0/traits' +import { ObservableV2 } from 'lib0/observable' +import { + Item, + ContentAny, + ContentBinary, + ContentDeleted, + ContentEmbed, + ContentFormat, + ContentJSON, + ContentString, + ContentType, + YXmlFragmentRefID, + YXmlElementRefID, + YXmlHookRefID, + ContentDoc, + createContentDocFromDoc +} from './structs/Item.js' +import { AttributedContent, rendererContentLength } from './utils/renderer-helpers.js' +import { removeEventHandlerListener, callEventHandlerListeners, addEventHandlerListener, createEventHandler } from './utils/EventHandler.js' +import { createID } from './utils/ID.js' +import { createIdSet, iterateStructsByIdSetWithoutSplits } from './utils/ids.js' +import { getItemCleanStart, cleanupFormattingGap } from './utils/transaction-helpers.js' +import { transact } from './utils/Transaction.js' +import { YEvent } from './utils/YEvent.js' +import { $doc } from './utils/schemas.js' + +/** + * @typedef {Object|Array|number|null|string|Uint8Array|BigInt|YNode} YValue + */ + +/** + * https://docs.yjs.dev/getting-started/working-with-shared-types#caveats + */ +export const warnPrematureAccess = () => { log.warn('Invalid access: Add Yjs type to a document before reading data.') } + +const maxSearchMarker = 80 + +/** + * @todo SHOULD NOT RETURN AN OBJECT! + * @param {Array>?} attrs + * @param {boolean} deleted - whether the attributed item is deleted + * @return {Attribution|undefined} `undefined` when there is no attribution — under lib0's tri-state + * that means "skip / inherit the builder's attribution context" (NOT `null`, which would clear it). + */ +export const createAttributionFromAttributionItems = (attrs, deleted) => { + if (attrs == null) { + return undefined + } + /** + * @type {Attribution} + */ + const attribution = {} + if (deleted) { + attribution.delete = [] + } else { + attribution.insert = [] + } + attrs.forEach(attr => { + switch (attr.name) { + // eslint-disable-next-line no-fallthrough + case 'insert': + case 'delete': { + // needs to be non-ambiguous: don't add existing attr if it doesn't match the actual status + attribution[attr.name]?.push(attr.val) + break + } + default: { + if (attr.name[0] !== '_') { + /** @type {any} */ (attribution)[attr.name] = attr.val + } + } + } + }) + return attribution +} + +/** + * A unique timestamp that identifies each marker. + * + * Time is relative,.. this is more like an ever-increasing clock. + * + * @type {number} + */ +let globalSearchMarkerTimestamp = 0 + +export class ItemTextListPosition { + /** + * @param {Item|null} left + * @param {Item|null} right + * @param {number} index + * @param {Map} currentFormats + * @param {AbstractRenderer?} renderer + */ + constructor (left, right, index, currentFormats, renderer) { + this.left = left + this.right = right + this.index = index + this.currentFormats = currentFormats + this.renderer = renderer + } + + /** + * Only call this if you know that this.right is defined + */ + forward () { + if (this.right === null) { + error.unexpectedCase() + } + switch (this.right.content.constructor) { + case ContentFormat: + if (!this.right.deleted) { + updateCurrentFormats(this.currentFormats, /** @type {ContentFormat} */ (this.right.content)) + } + break + default: + this.index += rendererContentLength(this.renderer, this.right) + break + } + this.left = this.right + this.right = this.right.right + } + + /** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {number} length + * @param {Object} formats + * + * @function + */ + formatText (transaction, parent, length, formats) { + minimizeFormatChanges(this, formats) + const negatedFormats = insertFormats(transaction, parent, this, formats) + // iterate until first non-format or null is found + // delete all formats with formats[format.key] != null + // also check the formats after the first non-format as we do not want to insert redundant negated formats there + // eslint-disable-next-line no-labels + iterationLoop: while ( + this.right !== null && + (length > 0 || + ( + negatedFormats.size > 0 && + ((this.right.deleted && rendererContentLength(this.renderer, this.right) === 0) || this.right.content.constructor === ContentFormat) + ) + ) + ) { + switch (this.right.content.constructor) { + case ContentFormat: { + if (!this.right.deleted) { + const { key, value } = /** @type {ContentFormat} */ (this.right.content) + const attr = formats[key] + if (attr !== undefined) { + if (equalFormats(attr, value)) { + negatedFormats.delete(key) + } else { + if (length === 0) { + // no need to further extend negatedFormats + // eslint-disable-next-line no-labels + break iterationLoop + } + negatedFormats.set(key, value) + } + this.right.delete(transaction) + } else { + this.currentFormats.set(key, value) + } + } + break + } + default: { + const item = this.right + const rightLen = rendererContentLength(this.renderer, item) + if (length < rightLen) { + if (this.renderer !== null && this.renderer.hasItem(item)) { + /** + * @type {Array>} + */ + const contents = [] + this.renderer.readContent(contents, item.id.client, item.id.clock, item.deleted, item.content, 0) + let i = 0 + for (; i < contents.length && length > 0; i++) { + const c = contents[i] + if ((!c.deleted || c.attrs != null) && c.content.isCountable()) { + length -= c.content.getLength() + } + } + if (length < 0 || (length === 0 && i !== contents.length)) { + const c = contents[--i] + getItemCleanStart(transaction, createID(item.id.client, c.clock + c.content.getLength() + length)) + } + } else { + // plain content: split directly at the offset + getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length)) + length = 0 + } + } else { + length -= rightLen + } + break + } + } + this.forward() + } + if (length > 0) { + throw new Error('Exceeded content range') + } + insertNegatedFormats(transaction, parent, this, negatedFormats) + } +} + +/** + * Negate applied formats + * + * @param {Transaction} transaction + * @param {YNode} parent + * @param {ItemTextListPosition} currPos + * @param {Map} negatedFormats + * + * @private + * @function + */ +const insertNegatedFormats = (transaction, parent, currPos, negatedFormats) => { + // check if we really need to remove formats + while ( + currPos.right !== null && ( + (currPos.right.deleted && rendererContentLength(currPos.renderer, currPos.right) === 0) || ( + currPos.right.content.constructor === ContentFormat && + equalFormats(negatedFormats.get(/** @type {ContentFormat} */ (currPos.right.content).key), /** @type {ContentFormat} */ (currPos.right.content).value) + ) + ) + ) { + if (!currPos.right.deleted) { + negatedFormats.delete(/** @type {ContentFormat} */ (currPos.right.content).key) + } + currPos.forward() + } + const doc = transaction.doc + const ownClientId = doc.clientID + negatedFormats.forEach((val, key) => { + const left = currPos.left + const right = currPos.right + const nextFormat = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)) + nextFormat.integrate(transaction, 0) + currPos.right = nextFormat + currPos.forward() + }) +} + +/** + * @param {Map} currentFormats + * @param {ContentFormat} format + * + * @private + * @function + */ +const updateCurrentFormats = (currentFormats, format) => { + const { key, value } = format + if (value === null) { + currentFormats.delete(key) + } else { + currentFormats.set(key, value) + } +} + +/** + * @param {ItemTextListPosition} currPos + * @param {Object} formats + * + * @private + * @function + */ +const minimizeFormatChanges = (currPos, formats) => { + // go right while formats[right.key] === right.value (or right is deleted) + while (true) { + if (currPos.right === null) { + break + } else if (currPos.right.deleted ? (rendererContentLength(currPos.renderer, currPos.right) === 0) : (!currPos.right.deleted && currPos.right.content.constructor === ContentFormat && equalFormats(formats[(/** @type {ContentFormat} */ (currPos.right.content)).key] ?? null, /** @type {ContentFormat} */ (currPos.right.content).value))) { + // + } else { + break + } + currPos.forward() + } +} + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {ItemTextListPosition} currPos + * @param {Object} formats + * @return {Map} + * + * @private + * @function + **/ +const insertFormats = (transaction, parent, currPos, formats) => { + const doc = transaction.doc + const ownClientId = doc.clientID + const negatedFormats = new Map() + // insert format-start items + for (const key in formats) { + const val = formats[key] + const currentVal = currPos.currentFormats.get(key) ?? null + if (!equalFormats(currentVal, val)) { + // save negated format (set null if currentVal undefined) + negatedFormats.set(key, currentVal) + const { left, right } = currPos + currPos.right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentFormat(key, val)) + currPos.right.integrate(transaction, 0) + currPos.forward() + } + } + return negatedFormats +} + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {ItemTextListPosition} currPos + * @param {import('./structs/Item.js').AbstractContent} content + * @param {Object} formats + * + * @private + * @function + **/ +export const insertContent = (transaction, parent, currPos, content, formats) => { + currPos.currentFormats.forEach((_val, key) => { + if (formats[key] === undefined) { + formats[key] = null + } + }) + const doc = transaction.doc + const ownClientId = doc.clientID + minimizeFormatChanges(currPos, formats) + const negatedFormats = insertFormats(transaction, parent, currPos, formats) + let { left, right, index } = currPos + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, content.getLength()) + } + right = new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, content) + right.integrate(transaction, 0) + currPos.right = right + currPos.index = index + currPos.forward() + insertNegatedFormats(transaction, parent, currPos, negatedFormats) +} + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {ItemTextListPosition} currPos + * @param {Array|string} insert + * @param {Object} formats + */ +export const insertContentHelper = (transaction, parent, currPos, insert, formats) => { + if (s.$string.check(insert)) { + insertContent(transaction, parent, currPos, new ContentString(insert), formats) + } else { + insert = insert.map(ins => delta.$deltaAny.check(ins) ? YNode.from(ins) : ins) + for (let i = 0; i < insert.length;) { + const first = insert[i] + if (first instanceof YNode) { + insertContent(transaction, parent, currPos, new ContentType(first), formats) + i++ + } else if ($doc.check(first)) { + insertContent(transaction, parent, currPos, createContentDocFromDoc(first), formats) + i++ + } else { + // insert "any" content + // compute slice len + let j = i + 1 + for (; j < insert.length && !(insert[j] instanceof YNode || $doc.check(insert[j])); j++) { /* nop */ } + insertContent(transaction, parent, currPos, new ContentAny((i === 0 && j === insert.length) ? insert : insert.slice(i, j)), formats) + i = j + } + } + } +} + +/** + * @param {Transaction} transaction + * @param {ItemTextListPosition} currPos + * @param {number} length + * @return {ItemTextListPosition} + * + * @private + * @function + */ +export const deleteText = (transaction, currPos, length) => { + const startLength = length + const startFormats = map.copy(currPos.currentFormats) + const start = currPos.right + while (length > 0 && currPos.right !== null) { + const item = currPos.right + if (!item.deleted && item.countable) { + if (length < item.length) { + getItemCleanStart(transaction, createID(item.id.client, item.id.clock + length)) + } + length -= item.length + item.delete(transaction) + } else if (currPos.renderer !== null && currPos.renderer.hasItem(item)) { + /** + * @type {Array>} + */ + const contents = [] + currPos.renderer.readContent(contents, item.id.client, item.id.clock, true, item.content, 0) + let splitClock = -1 + for (let i = 0; i < contents.length; i++) { + const c = contents[i] + if (c.content.isCountable() && c.attrs != null) { + if (length === 0) { + // the delete is exhausted but this item renders more content — split so the cursor + // advances only past the consumed part (mirrors formatText's renderer branch); + // otherwise every following op of the same delta targets too far right + splitClock = c.clock + break + } + // deleting already deleted content. store that information in a meta property, but do + // nothing + const pieceLen = c.content.getLength() + const contentLen = math.min(pieceLen, length) + map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen) + length -= contentLen + if (contentLen < pieceLen) { + splitClock = c.clock + contentLen + break + } + } + } + if (splitClock >= 0) { + getItemCleanStart(transaction, createID(item.id.client, splitClock)) + } else { + const lastContent = contents.length > 0 ? contents[contents.length - 1] : null + const nextItemClock = item.id.clock + item.length + const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock + if (nextContentClock < nextItemClock) { + getItemCleanStart(transaction, createID(item.id.client, nextContentClock)) + } + } + } + currPos.forward() + } + if (start) { + cleanupFormattingGap(transaction, start, currPos.right, startFormats, currPos.currentFormats) + } + const parent = /** @type {YNode} */ (/** @type {Item} */ (currPos.left || currPos.right).parent) + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, currPos.index, -startLength + length) + } + return currPos +} + +export class ArraySearchMarker { + /** + * @param {Item} p + * @param {number} index + */ + constructor (p, index) { + p.marker = true + this.p = p + this.index = index + this.timestamp = globalSearchMarkerTimestamp++ + } +} + +/** + * @param {ArraySearchMarker} marker + */ +const refreshMarkerTimestamp = marker => { marker.timestamp = globalSearchMarkerTimestamp++ } + +/** + * This is rather complex so this function is the only thing that should overwrite a marker + * + * @param {ArraySearchMarker} marker + * @param {Item} p + * @param {number} index + */ +const overwriteMarker = (marker, p, index) => { + marker.p.marker = false + marker.p = p + p.marker = true + marker.index = index + marker.timestamp = globalSearchMarkerTimestamp++ +} + +/** + * @param {Array} searchMarker + * @param {Item} p + * @param {number} index + */ +const markPosition = (searchMarker, p, index) => { + if (searchMarker.length >= maxSearchMarker) { + // override oldest marker (we don't want to create more objects) + const marker = searchMarker.reduce((a, b) => a.timestamp < b.timestamp ? a : b) + overwriteMarker(marker, p, index) + return marker + } else { + // create new marker + const pm = new ArraySearchMarker(p, index) + searchMarker.push(pm) + return pm + } +} + +/** + * Search marker help us to find positions in the associative array faster. + * + * They speed up the process of finding a position without much bookkeeping. + * + * A maximum of `maxSearchMarker` objects are created. + * + * This function always returns a refreshed marker (updated timestamp) + * + * @param {YNode} yarray + * @param {number} index + */ +export const findMarker = (yarray, index) => { + if (yarray._start === null || index === 0 || yarray._searchMarker === null) { + return null + } + const marker = yarray._searchMarker.length === 0 ? null : yarray._searchMarker.reduce((a, b) => math.abs(index - a.index) < math.abs(index - b.index) ? a : b) + let p = yarray._start + let pindex = 0 + if (marker !== null) { + p = marker.p + pindex = marker.index + refreshMarkerTimestamp(marker) // we used it, we might need to use it again + } + // iterate to right if possible + while (p.right !== null && pindex < index) { + if (!p.deleted && p.countable) { + if (index < pindex + p.length) { + break + } + pindex += p.length + } + p = p.right + } + // iterate to left if necessary (might be that pindex > index) + while (p.left !== null && pindex > index) { + p = p.left + if (!p.deleted && p.countable) { + pindex -= p.length + } + } + // we want to make sure that p can't be merged with left, because that would screw up everything + // in that cas just return what we have (it is most likely the best marker anyway) + // iterate to left until p can't be merged with left + while (p.left !== null && p.left.id.client === p.id.client && p.left.id.clock + p.left.length === p.id.clock) { + p = p.left + if (!p.deleted && p.countable) { + pindex -= p.length + } + } + if (marker !== null && math.abs(marker.index - pindex) < /** @type {any} */ (p.parent).length / maxSearchMarker) { + // adjust existing marker + overwriteMarker(marker, p, pindex) + return marker + } else { + // create new marker + return markPosition(yarray._searchMarker, p, pindex) + } +} + +/** + * Update markers when a change happened. + * + * This should be called before doing a deletion! + * + * @param {Array} searchMarker + * @param {number} index + * @param {number} len If insertion, len is positive. If deletion, len is negative. + */ +export const updateMarkerChanges = (searchMarker, index, len) => { + for (let i = searchMarker.length - 1; i >= 0; i--) { + const m = searchMarker[i] + if (len > 0) { + /** + * @type {Item|null} + */ + let p = m.p + p.marker = false + // Ideally we just want to do a simple position comparison, but this will only work if + // search markers don't point to deleted items for formats. + // Iterate marker to prev undeleted countable position so we know what to do when updating a position + while (p && (p.deleted || !p.countable)) { + p = p.left + if (p && !p.deleted && p.countable) { + // adjust position. the loop should break now + m.index -= p.length + } + } + if (p === null || p.marker === true) { + // remove search marker if updated position is null or if position is already marked + searchMarker.splice(i, 1) + continue + } + m.p = p + p.marker = true + } + if (index < m.index || (len > 0 && index === m.index)) { // a simple index <= m.index check would actually suffice + m.index = math.max(index, m.index + len) + } + } +} + +/** + * Accumulate all (list) children of a type and return them as an Array. + * + * @param {YNode} t + * @return {Array} + */ +export const getNodeChildren = t => { + t.doc ?? warnPrematureAccess() + let s = t._start + const arr = [] + while (s) { + arr.push(s) + s = s.right + } + return arr +} + +/** + * Call event listeners with an event. This will also add an event to all + * parents (for `.observeDeep` handlers). + * + * @param {YNode} type + * @param {Transaction} transaction + * @param {YEvent} event + */ +export const callTypeObservers = (type, transaction, event) => { + const changedType = type + const changedParentTypes = transaction.changedParentTypes + // track the event unconditionally — also for deleted types: the walk carries changes inside a + // deleted type (e.g. a suggestion-deleted tombstone that a custom renderer still renders) up to + // the deep-event / 'delta' / cache consumers on live ancestors. + while (true) { + // @ts-ignore + map.setIfUndefined(changedParentTypes, type, () => []).push(event) + if (type._item === null) { + break + } + type = /** @type {YNode} */ (type._item.parent) + } + // a deleted type's own observers stay silent (deleted content is invisible) — unless a + // renderer is attached to it, which may still render the type (mirrors the fire-time rule for + // `changedParentTypes` targets in `cleanupTransactions`). + if (changedType._item === null || !changedType._item.deleted || changedType._renderer !== null) { + callEventHandlerListeners(/** @type {any} */ (changedType._eH), event, transaction) + } +} + +/** + * Abstract Yjs Type class. + * + * A `YNode` is a {@link https://github.com/dmonad/lib0 lib0} `RDT` ("replicated data type", see + * `lib0/delta/rdt.js`): it emits a `'delta'` event whenever its state changes (carrying the change + * and the origin of the transaction that caused it), accepts foreign + * changes via {@link YNode#applyDelta}, exposes its delta {@link YNode#$delta schema}, and can be + * torn down via {@link YNode#destroy}. This lets a `YNode` be `bind()`-ed to any other RDT (another + * `YNode`, an in-memory delta, a DOM subtree, …). The legacy {@link YNode#observe `observe`} / + * {@link YNode#observeDeep `observeDeep`} `YEvent` API continues to work alongside the `'delta'` + * channel. + * + * @template {delta.DeltaConf} [DConf=any] + * @extends {ObservableV2<{ delta: (delta: delta.Delta, origin: any) => void, destroy: (type: YNode) => void }>} + */ +export class YNode extends ObservableV2 { + /** + * @param {delta.DeltaConfGetName?} name + */ + constructor (name = null) { + super() + /** + * @type {delta.DeltaConfGetName} + */ + this.name = /** @type {delta.DeltaConfGetName} */ (name) + /** + * @type {Item|null} + */ + this._item = null + /** + * @type {Map} + */ + this._map = new Map() + /** + * @type {Item|null} + */ + this._start = null + /** + * @type {Doc|null} + */ + this.doc = null + this._length = 0 + /** + * Event handlers + * @type {EventHandler>,Transaction>} + */ + this._eH = createEventHandler() + /** + * Deep event handlers + * @type {EventHandler,Transaction>} + */ + this._dEH = createEventHandler() + /** + * @type {null | Array} + */ + this._searchMarker = null + /** + * Maintained deep-delta cache backing {@link YNode#delta}. `null` until `delta` is first + * accessed; thereafter kept current on every event of this type (incrementally, by applying the + * deep change) and re-diffed by {@link YNode#useRenderer}. Cleared by {@link YNode#clearCache}. + * + * INVARIANT (fingerprint-memo safety): consumers (e.g. y-sync) diff against this LIVE object + * via {@link YNode#delta}, which memoizes `_fingerprint` on its delta/op nodes at every depth. + * Every in-place patch — the per-transaction `apply` in `cleanupTransactions` and the + * renderer-overlay `apply` in {@link typeApplyRendererChange} — MUST route through the lib0 + * delta builder API (`apply`), which invalidates those memos. NEVER write delta/op fields + * directly: a direct write leaves stale memos, corrupting every subsequent fingerprint read + * and diff. Pinned by the `testRdtFingerprintMemo*CacheDrift` tests. + * @type {delta.DeltaBuilderAny | null} + */ + this._delta = null + this._legacyTypeRef = this.name == null ? YXmlFragmentRefID : YXmlElementRefID + /** + * @type {Array|null} + */ + this._searchMarker = [] + /** + * Whether this YText contains formats. + * This flag is updated when a formatting item is integrated (see ContentFormat.integrate) + */ + this._hasFormatting = false + /** + * The active default renderer. Used by `toDelta`, `applyDelta`, and the events whenever no + * explicit renderer is passed. `null` = no renderer: content renders as-is via the generic + * fast path. Change it via {@link YNode#useRenderer}. + * @type {AbstractRenderer?} + */ + this._renderer = null + /** + * Bound listener on the active renderer's `'change'` event — attribution corrections that + * happen without a Y transaction on this doc (e.g. a suggestion is accepted and the + * renderer's attribution overlay updates). `null` while no renderer is active. + * Managed by {@link YNode#useRenderer}; see {@link typeApplyRendererChange}. + * @type {((changes: IdSet, origin: any, local: boolean) => void) | null} + */ + this._rendererChangeHandler = null + } + + /** + * Schema of the deltas this type produces — part of the lib0 `RDT` interface. + * + * @type {s.Schema>} + */ + get $delta () { + return /** @type {any} */ (delta.$deltaAny) + } + + /** + * The deep delta of this type (the full nested content tree, children rendered as their own + * deltas). + * + * The returned value is the type's **live** maintained cache: it is materialized on first access + * and then kept current on every event fired on this type (and re-diffed by + * {@link YNode#useRenderer}), so a reference held across edits keeps updating in place. Clone it + * (e.g. `type.delta.clone()`) if you need a stable snapshot, and call {@link YNode#clearCache} to + * drop the cache. + * + * Consider the returned delta **done** — it must not be edited from the outside. It is + * deliberately typed as a `Delta` (not a `DeltaBuilder`) so the mutating builder API is not + * reachable; editing it anyway would corrupt the cache without changing the CRDT. The proper way + * to change this type is {@link YNode#applyDelta}. + * + * @type {delta.Delta} + */ + get delta () { + if (this._delta === null) { + this._delta = this._renderDelta() + } + return /** @type {any} */ (this._delta) + } + + /** + * Render the full deep current state into a fresh `isFinal` builder (so subsequent `.apply`s of + * deep changes update content in place). Uses this type's active renderer. + * + * @return {delta.DeltaBuilderAny} + */ + _renderDelta () { + const state = /** @type {delta.DeltaBuilderAny} */ (delta.create(this.name)) + state.isFinal = true + state.apply(this.toDelta({ deep: true })) + return state + } + + /** + * Discard the cached deep delta backing {@link YNode#delta}. + * + * After `delta` is first accessed, the cache is updated on every event fired on this type (and + * re-diffed by {@link YNode#useRenderer}). Call this to drop it — e.g. to reclaim memory, or to + * force an exact recomputation after editing while a non-base renderer is active (the incremental + * updates can drift from a fresh deep render in that case). + */ + clearCache () { + this._delta = null + } + + /** + * Change the default renderer used by this type. After calling `useRenderer(renderer)`, the + * `toDelta`, `applyDelta`, and event methods all use `renderer` whenever no explicit renderer is + * passed (an explicit `{ renderer }` argument still overrides it per call). + * + * If the deep-delta cache ({@link YNode#delta}) is being maintained, or a `'delta'` listener is + * attached, the content is re-rendered with the new renderer and the difference is emitted on the + * `'delta'` channel only (a renderer switch is not a CRDT change, so no `YEvent` is produced, and + * the emitted origin is `null` as no transaction is involved). + * + * @param {AbstractRenderer?} renderer - `null` detaches: content renders as-is again + * @return {this} + */ + useRenderer (renderer) { + const prev = this._renderer + if (renderer === prev) return this + if (this._rendererChangeHandler !== null) { + /** @type {AbstractRenderer} */ (prev).off('change', this._rendererChangeHandler) + this._rendererChangeHandler = null + } + if (renderer !== null && this._rendererChangeHandler === null) { + // attribution corrections (e.g. accepting a suggestion) reach the renderer without a Y + // transaction on this doc — subscribe so the RDT surface (maintained cache + 'delta' + // channel) stays current. Without a renderer there are no attributions, hence no + // subscription. Not gated on `renderer !== prev`: after `destroy()` the handler is removed + // while `_renderer` keeps pointing at the renderer, so re-activating with the same renderer + // must re-subscribe. + this._rendererChangeHandler = (changes, origin) => typeApplyRendererChange(this, changes, origin) + renderer.on('change', this._rendererChangeHandler) + } + const hasDeltaListeners = (this._observers.get('delta')?.size ?? 0) > 0 + if (this._delta !== null || hasDeltaListeners) { + const oldState = this._delta ?? this._renderDelta() + this._renderer = renderer + const newState = this._renderDelta() + if (this._delta !== null) this._delta = newState + if (hasDeltaListeners) { + const d = /** @type {any} */ (delta.diff(/** @type {any} */ (oldState), /** @type {any} */ (newState))) + if (!d.isEmpty()) this.emit('delta', [d, null]) + } + } else { + this._renderer = renderer + } + return this + } + + /** + * Tear down this type as an `RDT`: emit the `'destroy'` event, unregister all `'delta'` / + * `'destroy'` listeners, and reset the RDT surface (active renderer detached, the + * maintained {@link YNode#delta} cache dropped). The CRDT content and the + * `observe`/`observeDeep` handlers are left untouched — this only releases the RDT/binding + * observers. Without the reset, a still-materialized cache would keep being maintained through + * the transaction path but no longer receive the renderer's attribution corrections — reading + * `delta` after `destroy()` would silently drift. Re-activating is fully supported: + * `useRenderer(renderer)` re-subscribes and `delta` re-materializes. + */ + destroy () { + if (this._rendererChangeHandler !== null) { + /** @type {AbstractRenderer} */ (this._renderer).off('change', this._rendererChangeHandler) + this._rendererChangeHandler = null + } + this._renderer = null + this._delta = null + this.emit('destroy', [this]) + super.destroy() + } + + /** + * @template {delta.DeltaConf} DC + * @param {delta.Delta} d + * @return {YNode} + */ + static from (d) { + const yt = new YNode(d.name) + yt.applyDelta(d) + return yt + } + + get length () { + this.doc ?? warnPrematureAccess() + return this._length + } + + /** + * Returns a fresh delta that can be used to change this YNode. + * @type {delta.DeltaBuilder>} + */ + get change () { + return /** @type {any} */ (delta.create()) + } + + /** + * @return {YNode?} + */ + get parent () { + return /** @type {YNode?} */ (this._item ? this._item.parent : null) + } + + /** + * Integrate this type into the Yjs instance. + * + * * Save this struct in the os + * * This type is sent to other client + * * Observer functions are fired + * + * @param {Doc} y The Yjs instance + * @param {Item|null} item + */ + _integrate (y, item) { + this.doc = y + this._item = item + if (this._prelim) { + this.applyDelta(this._prelim) + this._prelim = null + } + } + + /** + * @return {YNode} + */ + _copy () { + const ynode = new YNode(this.name) + ynode._legacyTypeRef = this._legacyTypeRef + return ynode + } + + /** + * Creates YEvent and calls all type observers. + * Must be implemented by each type. + * + * @param {Transaction} transaction + * @param {Set} parentSubs Keys changed on this type. `null` if list was modified. + */ + _callObserver (transaction, parentSubs) { + const event = new YEvent(/** @type {any} */ (this), transaction, parentSubs) + callTypeObservers(/** @type {any} */ (this), transaction, event) + // Note: the RDT `'delta'` channel (and the deep-delta cache) is driven in the transaction + // cleanup's `changedParentTypes` loop (see Transaction.js) so it bubbles to ancestors like + // `observeDeep`, not here where only the directly-changed type is visible. + if (!transaction.local && this._searchMarker) { + this._searchMarker.length = 0 + } + // If a remote change happened, we try to cleanup potential formatting duplicates. + if (!transaction.local && this._hasFormatting) { + transaction._needFormattingCleanup = true + } + } + + /** + * Observe all events that are created on this type. + * + * @template {(target: YEvent>, tr: Transaction) => void} F + * @param {F} f Observer function + * @return {F} + */ + observe (f) { + addEventHandlerListener(this._eH, f) + return f + } + + /** + * Observe all events that are created by this type and its children. + * + * @template {function(YEvent,Transaction):void} F + * @param {F} f Observer function + * @return {F} + */ + observeDeep (f) { + addEventHandlerListener(this._dEH, f) + return f + } + + /** + * Unregister an observer function. + * + * @param {(type:YEvent>,tr:Transaction)=>void} f Observer function + */ + unobserve (f) { + removeEventHandlerListener(this._eH, f) + } + + /** + * Unregister an observer function. + * + * @param {function(YEvent,Transaction):void} f Observer function + */ + unobserveDeep (f) { + removeEventHandlerListener(this._dEH, f) + } + + /** + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the + * attribution `{ isDeleted: true, .. }`. + * + * @template {boolean} [Deep=false] + * + * @param {Object} [opts] + * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed + * @param {IdSet?} [opts.itemsToRender] + * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions + * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only + * @param {IdSet?} [opts.insertedItems] - ids inserted by the change being rendered; content that is both in `insertedItems` and deleted renders as a *fresh* insert even under `retainDeletes` (it was never part of the consuming state) + * @param {Map>|null} [opts.modified] - set of types that should be rendered as modified children + * @param {Deep} [opts.deep] - render child types as delta + * @return {Deep extends true ? delta.Delta : delta.Delta>} The Delta representation of this type. + * + * @public + */ + toDelta (opts = {}) { + const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, insertedItems = null, deep = false } = opts + const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts + const renderAttrs = modified?.get(this) || null + const renderChildren = modified == null || !modified.has(this) || /** @type {Set} */ (modified.get(this)).has(null) + /** + * @type {delta.DeltaBuilderAny} + */ + const d = /** @type {any} */ (delta.create(this.name)) + const optsAll = object.assign({}, opts, { renderer, modified }) + // opts has been re-computed - do not use opts after this point! + nodeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, itemsToRender, optsAll, optsAll) + if (renderChildren) { + /** + * @type {delta.Formats} + */ + let currentFormats = {} // saves all current formats for insert + let usingCurrentFormats = false + /** + * @type {delta.Formats} + */ + let changedFormats = {} // saves changed formats for retain + let usingChangedFormats = false + /** + * Logic for format attribution + * Everything that comes after a format is formatted by the user that created it. + * Two exceptions: + * - the user resets formatting to the previously known formatting that is not attributed + * - the user deletes a format and hence restores the previously known formatting + * that is not attributed. + * @type {delta.Formats} + */ + const previousUnattributedFormats = {} // contains previously known unattributed formatting + /** + * @type {delta.Formats} + */ + const previousFormats = {} // The value before changes + /** + * Per-key provenance of `currentFormats`: `true` iff the marker currently governing the key + * opened a *pending attributed* range (the attributor-push branch below). Consulted only at + * deleted markers in change renders (`isDeletedFormatClear`) to distinguish a re-exposed + * committed enclosing value (must clear the stale attribution from the cache) from a + * re-exposed pending suggested value (must preserve it — the re-bolding case). + * @type {{ [key: string]: boolean }} + */ + const currentFormatsAttributed = {} + /** + * Attribution argument for a change-render retain whose content lost its *own* attribution. + * `null` clears everything — correct in an attribution-free context — but inside an ambient + * format attribution (installed via `useAttribution` by a surrounding format marker) the + * fresh render still stamps that ambient format on this content. An object argument merges + * over the ambient context (lib0 `combineInstr`), so emit per-key removals instead: they + * clear only the op's own insert/delete attribution while re-asserting the ambient format. + */ + const clearedOwnAttribution = () => d.usedAttribution == null ? null : /** @type {any} */ ({ insert: null, insertAt: null, delete: null, deleteAt: null }) + /** + * Format keys touched by a *rendered* format marker during an attributing change render. A + * merely retained attributed marker normally must not emit ops — but when a rendered + * same-key marker preceded it in this walk, the change is what made the retained boundary + * reset-to-previous (e.g. a base-doc format arriving under a same-key format suggestion), + * and the spans it governs must drop their now-stale attribution from the cache. Per-key + * tracking is required (a plain "any format rendered" flag would over-clear other keys), + * but the set is only ever consulted at format markers — never per character — and stays + * `null` on all hot paths: full renders, base-renderer renders, and change renders without + * format markers never allocate it. + * @type {Set?} + */ + let renderedFormatKeys = null + /** + * Renderer output for a single item — a renderer may split an item into multiple + * attributed pieces. + * @type {Array>} + */ + const cs = [] + /** + * Process one piece of (possibly attributed) content — the shared op-emission and format + * state machine. The renderer path feeds it every piece produced by `readContent`; the + * generic path feeds it `ContentFormat` markers only (the state machine must see every + * marker, attributed or not — a plain marker can e.g. close a formerly attributed range) + * and emits ops for plain content directly. + * @param {AttributedContent} c + */ + const processContent = c => { + // render (attributed) content even if it was deleted + const renderContent = c.render && (!c.deleted || c.attrs != null) + // content that was just deleted. It is not rendered as an insertion, because it doesn't + // have any formats. + const renderDelete = c.render && c.deleted + // existing content that should be retained, only adding changed formats + const retainContent = !c.render && (!c.deleted || c.attrs != null) + const attribution = (renderContent || c.content.constructor === ContentFormat) ? createAttributionFromAttributionItems(c.attrs, c.deleted) : undefined + switch (c.content.constructor) { + case ContentDeleted: { + // fresh (mode 3) content renders as nothing here: the consuming state never saw it, + // and gc'd content cannot render as an insert — a delete op would misapply + if (renderDelete && !c.fresh) d.delete(c.content.getLength()) + break + } + case ContentString: + if (renderContent) { + if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) { + // a retain expresses the format *diff* against existing (cached) content, so use + // `changedFormats`: a format removed this change (e.g. its marker was deleted) + // is present there as a `null` clear, whereas `currentFormats` (absolute) can + // only re-assert present formats and would silently keep a stale one. + // (`c.fresh` content is exempt from `retainDeletes`: it was inserted *and* + // deleted by this very change, so the consuming state holds nothing to retain — + // it renders as an insert below, carrying its attribution.) + d.useFormats(changedFormats) + usingChangedFormats = true + // change render: a retained item with no attribution means its attribution was + // removed → emit a clear rather than `{}` (skip). Present attribution merges. + d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? clearedOwnAttribution()) + } else { + d.useFormats(currentFormats) + usingCurrentFormats = true + d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution) + } + } else if (renderDelete) { + d.delete(c.content.getLength()) + } else if (retainContent) { + d.useFormats(changedFormats) + usingChangedFormats = true + d.retain(c.content.getLength()) + } + break + case ContentEmbed: + case ContentAny: + case ContentJSON: + case ContentType: + case ContentBinary: + case ContentDoc: + if (renderContent) { + if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) { + // a retain expresses the format *diff* → use `changedFormats` (see ContentString) + d.useFormats(changedFormats) + usingChangedFormats = true + if (c.deleted && c.content.constructor === ContentType) { + // @todo use current transaction instead + d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? null) + } else if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) { + // a de/re-attributed node the renderer still claims (e.g. a partial accept): + // merge/clear its own attribution and descend so the nested walk heals exactly + // the child ids in `itemsToRender` — other children keep their attribution + d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll), undefined, attribution ?? clearedOwnAttribution()) + } else { + d.retain(c.content.getLength(), undefined, attribution ?? clearedOwnAttribution()) + } + } else if (deep && c.content.constructor === ContentType) { + d.useFormats(currentFormats) + usingCurrentFormats = true + d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution) + } else { + d.useFormats(currentFormats) + usingCurrentFormats = true + d.insert(c.content.getContent(), undefined, attribution) + } + } else if (renderDelete) { + d.delete(1) + } else if (retainContent) { + if (c.content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (c.content).type)) { + // @todo use current transaction instead + d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll)) + } else { + d.useFormats(changedFormats) + usingChangedFormats = true + d.retain(1) + } + } + break + case ContentFormat: { + const { key, value } = /** @type {ContentFormat} */ (c.content) + const currFormatVal = currentFormats[key] ?? null + if (attribution != null && (c.deleted || !object.hasProperty(previousUnattributedFormats, key))) { + previousUnattributedFormats[key] = c.deleted ? value : currFormatVal + } + // @todo write a function "updateCurrentFormats" and "updateChangedFormats" + // # Update Formats + if (renderContent || renderDelete) { + // create fresh references + if (usingCurrentFormats) { + currentFormats = object.assign({}, currentFormats) + usingCurrentFormats = false + } + if (usingChangedFormats) { + usingChangedFormats = false + changedFormats = object.assign({}, changedFormats) + } + } + if (renderContent || renderDelete) { + if (itemsToRender !== null && renderer !== null) { + // only attributing change renders consult this (see the reset branch below) + if (renderedFormatKeys === null) renderedFormatKeys = new Set() + renderedFormatKeys.add(key) + } + // `previousFormats` tracks the value governing the current walk position in the + // *consuming cache state*, so the retain diff below stays a plain cache → new-state + // comparison. Contributors: markers deleted by this change in event renders (their + // value governed the cache until now; heal-rendered deletes are PRE-deleted markers + // whose removal the cache already rendered — they must not contribute), alive + // markers re-rendered by heal renders (`retainInserts` — their value was in the + // cache), and retained alive markers (the branch below). A gated update here is not + // enough: a marker deleted by the change whose value coincides with the walk's + // current value would silently drop out of the cache-reference tracking, and a later + // marker would be misread as a restore-to-previous, swallowing a needed format diff. + if (c.deleted) { + if (itemsToRender !== null && !retainInserts) previousFormats[key] = value + } else { // !c.deleted + if (retainInserts) previousFormats[key] = value + if (value == null) { + delete currentFormats[key] + } else { + currentFormats[key] = value + } + currentFormatsAttributed[key] = false + } + // the retain diff for following spans is exactly cache (previousFormats) → new + // state (currentFormats), recomputed per key at every rendered marker + if (equalFormats(currentFormats[key] ?? null, previousFormats[key] ?? null)) { + delete changedFormats[key] + } else { + changedFormats[key] = currentFormats[key] ?? null + } + } else if (retainContent && !c.deleted) { + // fresh reference to currentFormats only + if (usingCurrentFormats) { + currentFormats = object.assign({}, currentFormats) + usingCurrentFormats = false + } + if (usingChangedFormats && changedFormats[key] !== undefined) { + usingChangedFormats = false + changedFormats = object.assign({}, changedFormats) + } + if (value == null) { + delete currentFormats[key] + } else { + currentFormats[key] = value + } + currentFormatsAttributed[key] = false + delete changedFormats[key] + previousFormats[key] = value + } + // # Update Attributions + // A format marker deleted in a change render under an *attributing* renderer nets to no + // attribution (its insert+delete suggestion cancels → `attribution == null`), yet it ends + // the attributed range it opened: the following retained content must drop the stale + // `{ format: { [key]: [] } }` the marker's insertion wrote to the cache. Emit an explicit + // `null` leaf for the key (a context-wide `useAttribution(null)` cannot carry a per-key + // clear). Conditions: only an attributing render (`renderer !== null`; without a + // renderer there are no attributions to clear), and only when the deletion reverts to + // a value that is not itself governed by a pending suggestion: either no value at all + // (`currFormatVal == null`) or a *committed* enclosing value (provenance tracked in + // `currentFormatsAttributed` — e.g. an accept/reject deleting a marker re-exposes the + // enclosing committed `bold:true`, and the span's stale suggested-format attribution + // must be cleared). If it reverts to a still-pending *attributed* value (deleting a + // `bold:null` marker re-exposes an enclosing attributed `bold:true`, as when + // re-bolding), the attribution is preserved. Suppressed entirely while an attributed + // same-key range is open (`previousUnattributedFormats` has the key): there the fresh + // render skips this marker — its governance belongs to the still-open attributed + // range, whose ambient context re-stamps the correct attribution on the following + // retains — mirroring the identical guard on `isAcceptedFormatClear`. + const isDeletedFormatClear = attribution == null && renderer !== null && renderDelete && c.deleted && itemsToRender != null && (currFormatVal == null || currentFormatsAttributed[key] !== true) && !equalFormats(value, currFormatVal) && !object.hasProperty(previousUnattributedFormats, key) + // The alive-marker analogue of `isDeletedFormatClear`: a format marker whose + // *attribution* was removed while the marker survives — its suggestion was accepted + // (the marker's id arrives via the renderer's `'change'` event). The original change + // render stamped `{ format: { [key]: [] } }` on the spans this marker governs, so the + // heal render must emit a per-key `null` leaf over those spans (a blunt + // `attribution: null` would also wipe unrelated attributions such as a co-located + // `{ delete: [] }`). Gated on `retainInserts` — the diff-against-existing-content + // mode used by attribution-heal renders: in a plain event render an unattributed + // alive marker is just new unattributed content and must not emit spurious clears. + // When the key is tracked as an open attributed range (`previousUnattributedFormats`) + // the accepted marker instead defers to the regular branch chain below, which mirrors + // the fresh render exactly: mid-range it *keeps* the ambient attribution (relative to + // the base doc the span's format is still a suggested change), and at the range end + // (`sameAsPreviousAttributions`) it closes the attribution context. + const isAcceptedFormatClear = attribution == null && renderer !== null && renderContent && !c.deleted && itemsToRender != null && retainInserts && !object.hasProperty(previousUnattributedFormats, key) + if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || object.hasProperty(previousUnattributedFormats, key)) { + /** + * @type {Attribution} + */ + const formattingAttribution = object.assign({}, d.usedAttribution) + const changedAttributedFormats = /** @type {{ [key: string]: Array|null }} */ (formattingAttribution.format = object.assign({}, formattingAttribution.format ?? {})) + const sameAsPreviousAttributions = equalFormats(previousUnattributedFormats[key], currentFormats[key] ?? null) + // An *unattributed* marker rendered by this change (e.g. a fresh base-doc marker) + // that closes an open attributed same-key range by restoring the previous + // unattributed value: the spans it governs must drop their stale attribution from + // the cache, exactly like the attributed range-end below. Mirrors the fresh render + // precisely: there, the reset only closes the ambient attribution when removing the + // key leaves the format context empty (the `useAttribution(null)` case below) — + // while another attributed key holds the context open, the removal happens on a + // copy that is never installed, so the key survives on the following spans and the + // change render must NOT clear it. + const isRangeEndClear = attribution == null && sameAsPreviousAttributions && renderContent && !c.deleted && itemsToRender != null && renderer !== null && object.hasProperty(previousUnattributedFormats, key) && object.every(changedAttributedFormats, (_v, k) => k === key) + if (isDeletedFormatClear || (isAcceptedFormatClear && changedAttributedFormats[key] == null)) { + // uniformly emit the per-key clear — also for a marker that *ends* a formerly + // attributed range: the attributed render emits a trailing per-key `null` past + // the closing marker as well (see the `attribution != null` case below), so the + // heal render mirrors that exact span; where the cache holds no stale + // attribution the clear merges as a no-op. + changedAttributedFormats[key] = null + delete previousUnattributedFormats[key] + } else if (isAcceptedFormatClear) { + // accepted marker inside a *still-attributed* (pending) range for the same key — + // the ambient context holds an attributor list, not a clear. Relative to the base + // doc the effective format of this span is still a suggested change, so the fresh + // render keeps attributing it (the `skip` case below): keep the ambient + // attribution rather than clearing it. + } else if (attribution == null && !sameAsPreviousAttributions) { + // skip + } else if (attribution == null || sameAsPreviousAttributions) { + // an unattributed format was found or an attributed format + // was found that resets to the previous status. When this format item is + // itself rendered this transaction (`renderContent || renderDelete`) in a change/diff + // render (`itemsToRender != null`), it is the END of an attributed format range: + // emit an explicit clear (a `null` leaf) so the retained content drops any stale + // `{ format: { [key]: [] } }` from the maintained `delta` cache — a bare context-skip + // (`delete`) would leave it in place. The same applies to a *retained* attributed + // boundary when a rendered same-key marker preceded it in this walk + // (`renderedFormatKeys`): the boundary only resets-to-previous *because of* the + // rendered change (e.g. a base-doc format arriving under an equal same-key format + // suggestion — which marker comes first depends on client ids), so the spans it + // governs must drop their now-stale attribution too. For a merely-retained boundary + // with no rendered same-key marker, or a full insert render (removal is already + // modeled as absence in `currentFormats`), just drop the key: a change render must + // not emit ops for unchanged ranges, and inserts must stay free of a spurious + // `{ format: { [key]: null } }`. + // `c.fresh`: a marker inserted (and possibly auto-deleted) by this very change — + // e.g. a base-doc format clear arriving inside a suggestion-deleted paragraph — + // stays on the retained path (mode 0, the fresh-format exemption), but when it + // resets-to-previous it is the range END of the attributed run in the new state + // and must clear the stale attribution from the cache like a rendered marker. + if (isRangeEndClear || (attribution != null && itemsToRender != null && (renderContent || renderDelete || c.fresh || renderedFormatKeys?.has(key) === true))) { + changedAttributedFormats[key] = null + } else { + delete changedAttributedFormats[key] + // pending per-key clears force this copy to install (the all-null disjunct + // below); installing after silently dropping this key would close its + // governance without the clear the fresh render's context-close implies, + // leaving a stale `{ format: { [key]: [] } }` on the following retained + // spans — re-add the key as an explicit clear. Inert outside attributing + // change renders: null leaves cannot exist in fresh contexts. + if (itemsToRender != null && !object.isEmpty(changedAttributedFormats) && object.every(changedAttributedFormats, v => v == null)) { + changedAttributedFormats[key] = null + } + } + delete previousUnattributedFormats[key] + } else { + // an attributed marker opens pending governance for the key — a deleted attributed + // marker never writes `currentFormats`, hence the alive gate; an attributed marker + // that value-equals the previous unattributed value takes the reset branch above + // and correctly yields committed governance + if (!c.deleted) currentFormatsAttributed[key] = true + const by = changedAttributedFormats[key] = (changedAttributedFormats[key]?.slice() ?? []) + by.push(...((c.deleted ? attribution.delete : attribution.insert) ?? [])) + const attributedAt = (c.deleted ? attribution.deleteAt : attribution.insertAt) + if (attributedAt) formattingAttribution.formatAt = attributedAt + } + if (object.isEmpty(changedAttributedFormats)) { + d.useAttribution(null) + } else if (attribution != null || isDeletedFormatClear || isAcceptedFormatClear || isRangeEndClear || (itemsToRender != null && object.every(changedAttributedFormats, v => v == null))) { + // the last disjunct: a change-render copy whose remaining leaves are ALL per-key + // null clears is the analogue of the fresh render's empty copy (fresh contexts + // never contain null leaves — they are produced only by the change-render-only + // predicates above) — install it, so an unattributed close marker's key deletion + // closes the governance while the pending cache clears still propagate; discarding + // it would re-merge the stale key into the cache from the surviving ambient + const attributedAt = (c.deleted ? attribution?.deleteAt : attribution?.insertAt) + if (attributedAt != null) formattingAttribution.formatAt = attributedAt + d.useAttribution(formattingAttribution) + } + } + break + } + } + } + for (let item = this._start; item !== null; item = item.right) { + const content = item.content + if (renderer === null || !renderer.hasItem(item)) { + // generic fast path: content the renderer doesn't claim renders as-is — no attribution + // lookups, no AttributedContent wrappers and, in the common full-coverage case, no + // content slicing + if (content.constructor === ContentFormat) { + // format markers always flow through the shared state machine + processContent(new AttributedContent(content, item.id.clock, item.deleted, null, + itemsToRender == null ? 1 : (itemsToRender.has(item.id.client, item.id.clock) ? 2 : 0))) + } else if (item.deleted) { + // plain deleted content is invisible; in a change render, the ranges deleted by this + // change emit `delete` ops — position-only, the content itself is not needed + if (itemsToRender !== null && itemsToRender.intersects(item.id.client, item.id.clock, item.length)) { + const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) + for (let ir = 0; ir < rslice.length; ir++) { + const idrange = rslice[ir] + if (idrange.exists) { + // mirror the piece-wise op sizes of the renderer path: string-ish content + // deletes by length, other content deletes one element per piece + d.delete((content.constructor === ContentString || content.constructor === ContentDeleted) ? idrange.len : 1) + } + } + } + } else if (itemsToRender == null) { + if (retainInserts) { + // attribution-overlay render (e.g. `toDelta({ renderer, retainInserts: true })`): + // existing content is retained, clearing any formerly cached own-attribution + d.useFormats(changedFormats) + usingChangedFormats = true + d.retain(content.getLength(), undefined, clearedOwnAttribution()) + } else { + // full render: a plain insert of the whole item + d.useFormats(currentFormats) + usingCurrentFormats = true + if (deep && content.constructor === ContentType) { + d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) + } else if (content.constructor === ContentString) { + d.insert(/** @type {ContentString} */ (content).str) + } else { + d.insert(content.getContent()) + } + } + } else { + // change render on alive plain content: inserted ranges become inserts, everything + // else retains (position-only). `retainInserts` (attribution-heal renders) retains + // previously inserted content while clearing its former attribution. + const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) + let itemContent = rslice.length > 1 ? content.copy() : content + for (let ir = 0; ir < rslice.length; ir++) { + const idrange = rslice[ir] + const c = itemContent + if (ir !== rslice.length - 1) { + itemContent = itemContent.splice(idrange.len) + } + if (!idrange.exists) { + if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) { + // @todo use current transaction instead + d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll)) + } else { + d.useFormats(changedFormats) + usingChangedFormats = true + // mirror the piece-wise op sizes of the renderer path (see the delete branch) + d.retain(content.constructor === ContentString ? idrange.len : 1) + } + } else if (retainInserts) { + if (content.constructor === ContentType && modified?.has(/** @type {ContentType} */ (content).type)) { + // a de/re-attributed node (e.g. its insert-suggestion was accepted): clear its + // own attribution and descend so the nested walk heals exactly the child ids + // present in `itemsToRender` — children outside the change set keep their + // attribution (id-scoped, never a blanket subtree clear) + d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll), undefined, clearedOwnAttribution()) + } else { + d.useFormats(changedFormats) + usingChangedFormats = true + d.retain(idrange.len, undefined, clearedOwnAttribution()) + } + } else { + d.useFormats(currentFormats) + usingCurrentFormats = true + if (deep && content.constructor === ContentType) { + d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) + } else if (content.constructor === ContentString) { + d.insert(/** @type {ContentString} */ (c).str) + } else { + d.insert(c.getContent()) + } + } + } + } + } else { + cs.length = 0 + if (itemsToRender != null) { + const rslice = itemsToRender.slice(item.id.client, item.id.clock, item.length) + // fresh content deleted by the very change being rendered gets mode 3: it renders as + // an insert even under `retainDeletes` — the consuming state (e.g. the maintained + // `delta` cache) has never seen it, so there is nothing to retain. Freshness is + // decided per id range, NOT per item: structs merge across cleanups (e.g. a queued + // transaction's render runs after an earlier finally-block merged its fresh item into + // an older neighbor), so one item can span fresh and pre-existing ids. ContentFormat + // markers are exempt and keep mode 0 (the retained-marker path; they never merge, so + // the whole-item check is exact): the format state machine treats fresh-deleted + // markers exactly like before this change rendered. + const checkFresh = item.deleted && insertedItems !== null && content.constructor !== ContentFormat + const freshFormat = item.deleted && insertedItems !== null && content.constructor === ContentFormat && insertedItems.hasId(item.id) + let itemContent = rslice.length > 1 ? content.copy() : content + for (let ir = 0; ir < rslice.length; ir++) { + const idrange = rslice[ir] + let c = itemContent + if (ir !== rslice.length - 1) { + itemContent = itemContent.splice(idrange.len) + } + if (!idrange.exists || !checkFresh) { + renderer.readContent(cs, item.id.client, idrange.clock, item.deleted, c, + idrange.exists ? (freshFormat ? 0 : 2) : 0) + } else { + // an exists range may itself straddle fresh and pre-existing ids (ranges from + // different sources merge in `itemsToRender`) — split it against `insertedItems` + const fslice = insertedItems.slice(item.id.client, idrange.clock, idrange.len) + let subContent = fslice.length > 1 ? c.copy() : c + for (let fi = 0; fi < fslice.length; fi++) { + const frange = fslice[fi] + c = subContent + if (fi !== fslice.length - 1) { + subContent = subContent.splice(frange.len) + } + renderer.readContent(cs, item.id.client, frange.clock, item.deleted, c, frange.exists ? 3 : 2) + } + } + } + if (freshFormat) { + // fresh format markers stay on the retained path (mode 0, see `checkFresh`), but + // the format state machine must still see their freshness: a fresh clear marker + // that resets-to-previous ends an attributed range and must emit the per-key + // attribution clear (`cs` is reset per item; a freshFormat item is a single + // len-1 ContentFormat, so this marks exactly its pieces) + for (let i = 0; i < cs.length; i++) { cs[i].fresh = true } + } + } else { + renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, content, 1) + } + for (let i = 0; i < cs.length; i++) { + processContent(cs[i]) + } + } + } + } + return /** @type {any} */ (d.done(false)) + } + + /** + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * @param {Object} [opts] + * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed + * @return {delta.Delta} + */ + toDeltaDeep (opts = {}) { + return /** @type {any} */ (this.toDelta({ ...opts, deep: true })) + } + + /** + * Apply a {@link Delta} on this shared type. + * + * @param {delta.DeltaAny} d The changes to apply on this element. + * @param {any} [origin] Origin of the transaction that applies this delta (stored on + * `transaction.origin` and forwarded verbatim on the emitted `'delta'` event, so listeners can + * recognize — and skip — changes they produced themselves; see the lib0 `RDT` spec). Defaults to `null`. + * @param {Object} [opts] + * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YNode#useRenderer}), i.e. `null` (render as-is) unless changed + * @return {delta.DeltaBuilder?} The lib0 `RDT` "fix" of this apply — a change measured against the + * caller's expected state (`old.apply(d)`) that transforms it into the actual state, or `null` + * when `d` applied cleanly. A fix is produced when `d` (or a nested `modify`/`modifyAttr`) + * addresses a *deleted but rendered* node (e.g. a suggestion-deleted paragraph under a + * DiffRenderer): that part of the change is not applied to the document, and its inverse + * (`lib0/delta` `inverse` against the node's rendered state) is returned — the change is + * immediately reverted. + * + * @public + */ + applyDelta (d, origin = null, { renderer = this._renderer } = {}) { + if (d.isEmpty()) return null + if (this.doc == null) { + (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) + return null + } + const titem = this._item + if (titem !== null && titem.deleted) { + if (rendererContentLength(renderer, titem) > 0) { + // deleted, but still rendered (e.g. a suggestion-deleted node): apply nothing — revert the + // whole change and return its inverse (against the rendered state the caller addressed) + const inv = delta.inverse(d, /** @type {any} */ (this.toDeltaDeep({ renderer }))) + return inv.isEmpty() ? null : /** @type {any} */ (inv) + } + return null // invisible deleted type: the caller's view shows nothing here — silently drop + } + // @todo this was moved here from ytext. Make this more generic + return transact(this.doc, transaction => { + /** + * The accumulated fix. Its coordinates live in the caller's *expected* space, so they are + * tracked from `d`'s own ops (`expectedIndex`) — not `currPos.index`, which also counts + * content that a delete over attributed-deleted ranges leaves rendered. + * + * @type {delta.DeltaBuilder?} + */ + let fix = null + let fixLen = 0 + let expectedIndex = 0 + /** + * @param {delta.DeltaAny?} childFix + * @param {{ [k:string]: any }} [invFormat] + */ + const appendModifyFix = (childFix, invFormat) => { + const f = fix ?? (fix = /** @type {any} */ (delta.create())) + expectedIndex > fixLen && f.retain(expectedIndex - fixLen) + f.modify(/** @type {any} */ (childFix ?? delta.create().done(false)), invFormat) + fixLen = expectedIndex + 1 + } + const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), renderer) + for (const op of d.children) { + if (delta.$textOp.check(op)) { + insertContent(transaction, /** @type {any} */ (this), currPos, new ContentString(op.insert), op.format || {}) + expectedIndex += op.length + } else if (delta.$insertOp.check(op)) { + insertContentHelper(transaction, this, currPos, op.insert, op.format || {}) + expectedIndex += op.length + } else if (delta.$retainOp.check(op)) { + currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {}) + expectedIndex += op.length + } else if (delta.$deleteOp.check(op)) { + deleteText(transaction, currPos, op.delete) + } else if (delta.$modifyOp.check(op)) { + let item = currPos.right + while (item !== null && rendererContentLength(renderer, item) === 0) { item = item.right } + if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() } + if (item.deleted) { + // deleted but rendered: revert instead of apply. Advance the cursor first (populating + // `currentFormats` with any markers up to the node) without applying `op.format`, then + // recurse — the child's deleted-guard applies nothing and returns the inverse. + currPos.formatText(transaction, /** @type {any} */ (this), 1, {}) + /** @type {{ [k:string]: any }|undefined} */ + let invFormat + for (const k in op.format) { + (invFormat ?? (invFormat = {}))[k] = currPos.currentFormats.get(k) ?? null + } + const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) + if (childFix !== null || invFormat !== undefined) { + appendModifyFix(childFix, invFormat) + } + } else { + const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) + currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) + if (childFix !== null) { + appendModifyFix(childFix) + } + } + expectedIndex += 1 + } else { + error.unexpectedCase() + } + } + for (const op of d.attrs) { + if (delta.$setAttrOp.check(op)) { + nodeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value) + } else if (delta.$deleteAttrOp.check(op)) { + nodeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key)) + } else { + // modifyAttr — locate the target renderer-aware: a deleted map value may still be rendered + const mapItem = this._map.get(/** @type {any} */ (op.key)) + const sub = mapItem === undefined + ? undefined + : (mapItem.deleted + ? (mapItem.content.constructor === ContentType && rendererContentLength(renderer, mapItem) > 0 + ? /** @type {ContentType} */ (mapItem.content).type + : undefined) + : mapItem.content.getContent()[mapItem.length - 1]) + if (!(sub instanceof YNode)) { + error.unexpectedCase() + } + const subFix = sub.applyDelta(op.value, origin, { renderer }) + if (subFix !== null) { + const f = fix ?? (fix = /** @type {any} */ (delta.create())) + f.modifyAttr(/** @type {any} */ (op.key), /** @type {any} */ (subFix)) + } + } + } + return fix !== null && !(/** @type {delta.DeltaBuilder} */ (fix).done(false).isEmpty()) ? fix : null + }, origin) + } + + /** + * Makes a copy of this data type that can be included somewhere else. + * + * Note that the content is only readable _after_ it has been included somewhere in the Ydoc. + * + * @return {YNode} + */ + clone () { + const cpy = this._copy() + cpy.applyDelta(this.toDeltaDeep()) + return cpy + } + + /** + * Removes all elements from this YMap. + */ + clearAttrs () { + const d = delta.create() + this.forEachAttr((_, key) => { + d.deleteAttr(/** @type {any} */ (key)) + }) + this.applyDelta(d) + } + + /** + * Removes an attribute from this YXmlElement. + * + * @param {string} attributeName The attribute name that is to be removed. + * + * @public + */ + deleteAttr (attributeName) { + this.applyDelta(delta.create().deleteAttr(attributeName).done()) + } + + /** + * Sets or updates an attribute. + * + * @template {Exclude,symbol>} KEY + * @template {delta.DeltaConfGetAttrs[KEY]} VAL + * + * @param {KEY} attributeName The attribute name that is to be set. + * @param {VAL} attributeValue The attribute value that is to be set. + * @return {VAL} + * + * @public + */ + setAttr (attributeName, attributeValue) { + this.applyDelta(delta.create().setAttr(attributeName, attributeValue).done()) + return attributeValue + } + + /** + * Returns an attribute value that belongs to the attribute name. + * + * @template {Exclude,symbol|number>} KEY + * @param {KEY} attributeName The attribute name that identifies the queried value. + * @return {delta.DeltaConfGetAttrs[KEY]|undefined} The queried attribute value. + * @public + */ + getAttr (attributeName) { + return /** @type {any} */ (nodeMapGet(this, attributeName)) + } + + /** + * Returns whether an attribute exists + * + * @param {string} attributeName The attribute name to check for existence. + * @return {boolean} whether the attribute exists. + * + * @public + */ + hasAttr (attributeName) { + return /** @type {any} */ (nodeMapHas(this, attributeName)) + } + + /** + * Returns all attribute name/value pairs in a JSON Object. + * + * @param {Snapshot} [snapshot] + * @return {{ [Key in Extract,string>]?: delta.DeltaConfGetAttrs[Key]}} A JSON Object that describes the attributes. + * + * @public + */ + getAttrs (snapshot) { + return /** @type {any} */ (snapshot ? nodeMapGetAllSnapshot(this, snapshot) : nodeMapGetAll(this)) + } + + /** + * Inserts new content at an index. + * + * Important: This function expects an array of content. Not just a content + * object. The reason for this "weirdness" is that inserting several elements + * is very efficient when it is done as a single operation. + * + * @example + * // Insert character 'a' at position 0 + * yarray.insert(0, ['a']) + * // Insert numbers 1, 2 at position 1 + * yarray.insert(1, [1, 2]) + * + * @param {number} index The index to insert content at. + * @param {Array>|delta.DeltaConfGetText} content Array of content to append. + * @param {delta.Formats} [format] + */ + insert (index, content, format) { + this.applyDelta(delta.create().retain(index).insert(/** @type {any} */ (content), format).done()) + } + + /** + * Inserts new content at an index. + * + * Important: This function expects an array of content. Not just a content + * object. The reason for this "weirdness" is that inserting several elements + * is very efficient when it is done as a single operation. + * + * @example + * // Insert character 'a' at position 0 + * yarray.insert(0, ['a']) + * // Insert numbers 1, 2 at position 1 + * yarray.insert(1, [1, 2]) + * + * @param {number} index The index to insert content at. + * @param {number} length The index to insert content at. + * @param {delta.Formats} formats + * + */ + format (index, length, formats) { + this.applyDelta(delta.create().retain(index).retain(length, formats)) + } + + /** + * Appends content to this YArray. + * + * @param {Array>|delta.DeltaConfGetText} content Array of content to append. + * + * @todo Use the following implementation in all types. + */ + push (content) { + this.insert(this.length, content) + } + + /** + * Prepends content to this YArray. + * + * @param {delta.DeltaConfGetText} content Array of content to prepend. + */ + unshift (content) { + this.insert(0, content) + } + + /** + * Deletes elements starting from an index. + * + * @param {number} index Index at which to start deleting elements + * @param {number} length The number of elements to remove. Defaults to 1. + */ + delete (index, length = 1) { + this.applyDelta(delta.create().retain(index).delete(length)) + } + + /** + * Returns the i-th element from a YArray. + * + * @param {number} index The index of the element to return from the YArray + * @return {delta.DeltaConfGetChildren} + */ + get (index) { + return typeListGet(this, index) + } + + /** + * Returns a portion of this YXmlFragment into a JavaScript Array selected + * from start to end (end not included). + * + * @param {number} [start] + * @param {number} [end] + * @return {Array>} + */ + slice (start = 0, end = this.length) { + return typeListSlice(this, start, end) + } + + /** + * @todo refactor this, this should use getContent only! + * + * Transforms this YArray to a JavaScript Array. + * + * @return {Array | delta.DeltaConfGetText>} + */ + toArray () { + const dcontent = this.toDelta() + /** + * @type {Array} + */ + const children = [] + for (const child of dcontent.children) { + if (delta.$insertOp.check(child)) { + for (let i = 0; i < child.insert.length; i++) { + children.push(child.insert[i]) + } + } else if (delta.$textOp.check(child)) { + children.push(child.insert) + } + } + return children + } + + /** + * Transforms this Shared Type to a JSON object. + * @return {{ name?: string, attrs?: { [K:string|number]: any }, children?: Array }} + */ + toJSON () { + /** + * @type {{[K:string]:any}} + */ + const attrs = this.getAttrs() + for (const k in attrs) { + const attr = attrs[k] + attrs[k] = attr instanceof YNode ? attr.toJSON() : attr + } + const children = this.toArray().map(child => child instanceof YNode ? /** @type {any} */ (child.toJSON()) : child) + /** + * @type {any} + */ + const res = {} + if (this.name != null) { + res.name = this.name + } + if (this.length > 0) { + res.children = children + } + if (this.attrSize > 0) { + res.attrs = attrs + } + return res + } + + /** + * @param {object} opts + * @param {boolean} [opts.forceTag] enforce creating a surrouning tag, even if it is null. + */ + toString ({ forceTag = false } = {}) { + /** + * @type {Array<[string|number,string]>} + */ + const attrs = [] + this.forEachAttr((attr, key) => { + attrs.push([(key), /** @type {any} */ (attr) instanceof YNode ? attr.toString({ forceTag: true }) : JSON.stringify(attr)]) + }) + const attrsString = (attrs.length > 0 ? ' ' : '') + attrs.sort((a, b) => a[0].toString() < b[0].toString() ? -1 : 1).map(attr => attr[0] + '=' + attr[1]).join(' ') + /** + * @type {string} + */ + const children = this.toArray().map(c => s.$string.check(c) ? c : (c instanceof YNode ? c.toString({ forceTag: true }) : JSON.stringify(c))).join('') + if (this.name == null && !forceTag && attrs.length === 0) { + return children + } + if (this.length === 0) { + return `<${this.name ?? ''}${attrsString} />` + } + return `<${this.name ?? ''}${attrsString}>${children}` + } + + /** + * Returns an Array with the result of calling a provided function on every + * child-element. + * + * @template M + * @param {(child:delta.DeltaConfGetChildren|delta.DeltaConfGetText,index:number)=>M} f Function that produces an element of the new Array + * @return {Array} A new array with each element being the result of the + * callback function + */ + map (f) { + return this.toArray().map(f) + } + + /** + * Executes a provided function once on every element of this YArray. + * + * @param {(child:delta.DeltaConfGetChildren|delta.DeltaConfGetText,index:number)=>any} f Function that produces an element of the new Array + */ + forEach (f) { + return this.toArray().forEach(f) + } + + /** + * Executes a provided function on once on every key-value pair. + * + * @param {(val:delta.DeltaConfGetAttrs[any],key:Exclude,symbol>,ynode:this)=>any} f + */ + forEachAttr (f) { + this._map.forEach((item, key) => { + if (!item.deleted) { + f(item.content.getContent()[item.length - 1], /** @type {any} */ (key), this) + } + }) + } + + /** + * Returns the keys for each element in the YMap Type. + * + * @return {IterableIterator>>} + */ + attrKeys () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[0]) + } + + /** + * Returns the values for each element in the YMap Type. + * + * @return {IterableIterator[any]>} + */ + attrValues () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1]) + } + + /** + * Returns an Iterator of [key, value] pairs + * + * @return {IterableIterator<{ [K in keyof delta.DeltaConfGetAttrs]: [K,delta.DeltaConfGetAttrs[K]] }[any]>} + */ + attrEntries () { + return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]])) + } + + /** + * Returns the number of stored attributes (count of key/value pairs) + * + * @return {number} + */ + get attrSize () { + return [...createMapIterator(this)].length + } + + /** + * @param {this} other + */ + [traits.EqualityTraitSymbol] (other) { + return this.toDelta().equals(other.toDelta()) + } + + /** + * @todo this doesn't need to live in a method. + * + * Transform the properties of this type to binary and write it to an + * BinaryEncoder. + * + * This is called when this Item is sent to a remote peer. + * + * @param {UpdateEncoderV1 | UpdateEncoderV2} encoder The encoder to write data to. + */ + _write (encoder) { + encoder.writeTypeRef(this._legacyTypeRef) + switch (this._legacyTypeRef) { + case YXmlElementRefID: + case YXmlHookRefID: { + encoder.writeKey(this.name) + break + } + } + } +} + +/** + * Schema of a {@link YNode} with any delta configuration. This is where the nominal `y:node` tag + * lives - {@link $node} builds on it. Mirrors lib0's `$deltaAny` / `$delta` split. + */ +export const $nodeAny = /** @type {s.Schema>} */ (YNode.prototype.$type = s.$type('y:node', YNode)) + +/** + * Schema of a {@link YNode} with a specific delta configuration. + * + * **Checked at runtime:** the nominal type, and the node's `name`. `name` is assigned once in the + * constructor and never reassigned, so the check is stable for the node's lifetime. + * + * **Not checked at runtime:** `attrs`, `children`, `text`, `formats`. A node does not carry its + * `DConf` - `name` is its only runtime residue, which is why {@link YNode#$delta} returns + * `$deltaAny`. Those parts are a type-level assertion, and they could not be checked soundly + * anyway: a node is a *concurrently* mutable container, so a check of its content would hold only + * for the instant it ran - a remote update can violate it immediately after, with no local code + * involved. To validate content, validate the immutable value instead: + * `delta.$delta(conf).check(node.toDelta())`. + * + * This is stricter than lib0's `$Delta.check`, which *skips* the name check when `name == null`. + * That is right for a delta (a nameless delta asserts nothing about the name) and wrong for a node + * (a nameless node is definitively a fragment). So `$node({ name: 'p' })` rejects `ydoc.get('key')`, + * whose `name` is `null` unless a name was passed as the *second* argument. + * + * Allocates a fresh schema per call - hoist it rather than calling it in a hot loop. For a cheap + * nominal gate use {@link $nodeAny} (one identity compare). + * + * @template {delta.ReadableDeltaConf} DConf + * @param {DConf} dconf + * @return {s.Schema>>} + */ +export const $node = dconf => { + // the same coercion lib0's `$delta` applies to `conf.name` + const $name = dconf.name == null ? s.$any : s.$(dconf.name) + return s.$custom(o => $nodeAny.check(o) && $name.check(o.name)) +} + +/** + * @param {StructStore} store + * @param {IdSet} items + */ +export const computeModifiedFromItems = (store, items) => { + /** + * @type {Map>} + */ + const modified = new Map() + iterateStructsByIdSetWithoutSplits(store, items, /** @param {Item | GC | Skip | null} item */ item => { + while (item instanceof Item) { + const parent = /** @type {YNode} */ (item.parent) + const conf = map.setIfUndefined(modified, parent, set.create) + if (conf.has(item.parentSub)) break // has already been marked as modified + conf.add(item.parentSub) + item = parent._item + } + }) + return modified +} + +/** + * The active renderer's attribution overlay changed (e.g. a suggestion was accepted or rejected) + * without a Y transaction on the type's doc, so no `YEvent` fires. Re-render the affected id + * ranges and keep the RDT surface current, mirroring the per-transaction upkeep in + * `cleanupTransactions`: patch the maintained {@link YNode#delta} cache and emit the change on + * the `'delta'` channel. Wired to the renderer's `'change'` event by {@link YNode#useRenderer}. + * + * Ranges in `changes` that the doc has not (yet) integrated — e.g. a base-doc edit whose update + * flows in only after the renderer event — simply render to nothing here; the later transaction + * covers them through the regular event path. + * + * The cache patch must stay on the builder `apply` — see the {@link YNode#_delta} + * fingerprint-memo invariant. + * + * @param {YNode} type + * @param {IdSet} changes + * @param {any} origin + * + * @private + * @function + */ +export const typeApplyRendererChange = (type, changes, origin) => { + const hasDeltaListeners = (type._observers.get('delta')?.size ?? 0) > 0 + // no deleted-type guard: this handler only exists while a custom renderer is attached (see + // `useRenderer`), and a custom renderer may still render a deleted type — its cache and 'delta' + // channel must stay current through accept/reject overlay changes. + if ((type._delta === null && !hasDeltaListeners) || type.doc == null) return + const change = type.toDelta({ renderer: type._renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true }) + if (!change.isEmpty()) { + type._delta?.apply(change) + if (hasDeltaListeners) type.emit('delta', [change, origin]) + } +} + +/** + * @param {any} a + * @param {any} b + * @return {boolean} + */ +export const equalFormats = (a, b) => a === b || (typeof a === 'object' && typeof b === 'object' && a && b && object.equalFlat(a, b)) + +/** + * @template {delta.DeltaConf} DConf + * @typedef {delta.DeltaConfOverwrite]: DeltaToYNode[K]> }, + * children: DeltaToYNode> + * }> + * } DeltaConfDeltaToYNode + */ + +/** + * @template {any} Data + * @typedef {Exclude | (Extract extends delta.Delta ? (unknown extends DConf ? YNode : never) : never)} DeltaToYNode + */ + +/** + * @param {YNode} type + * @param {number} start + * @param {number} end + * @return {Array} + * + * @private + * @function + */ +export const typeListSlice = (type, start, end) => { + type.doc ?? warnPrematureAccess() + if (start < 0) { + start = type._length + start + } + if (end < 0) { + end = type._length + end + } + let len = end - start + const cs = [] + let n = type._start + while (n !== null && len > 0) { + if (n.countable && !n.deleted) { + const c = n.content.getContent() + if (c.length <= start) { + start -= c.length + } else { + for (let i = start; i < c.length && len > 0; i++) { + cs.push(c[i]) + len-- + } + start = 0 + } + } + n = n.right + } + return cs +} + +/** + * @todo remove / inline this + * + * @param {YNode} type + * @param {number} index + * @return {any} + * + * @private + * @function + */ +export const typeListGet = (type, index) => { + type.doc ?? warnPrematureAccess() + const marker = findMarker(type, index) + let n = type._start + if (marker !== null) { + n = marker.p + index -= marker.index + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + return n.content.getContent()[index] + } + index -= n.length + } + } +} + +/** + * @todo this is a duplicate. use the unified insert function and remove this. + * + * @param {Transaction} transaction + * @param {YNode} parent + * @param {Item?} referenceItem + * @param {Array} content + * + * @private + * @function + */ +export const typeListInsertGenericsAfter = (transaction, parent, referenceItem, content) => { + let left = referenceItem + const doc = transaction.doc + const ownClientId = doc.clientID + const store = doc.store + const right = referenceItem === null ? parent._start : referenceItem.right + /** + * @type {Array|number|null>} + */ + let jsonContent = [] + const packJsonContent = () => { + if (jsonContent.length > 0) { + left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentAny(jsonContent)) + left.integrate(transaction, 0) + jsonContent = [] + } + } + content.forEach(c => { + if (c === null) { + jsonContent.push(c) + } else { + switch (c.constructor) { + case Number: + case Object: + case undefined: + case Boolean: + case Array: + case String: + case BigInt: + case Date: + jsonContent.push(c) + break + default: + packJsonContent() + switch (c.constructor) { + case Uint8Array: + case ArrayBuffer: + left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentBinary(new Uint8Array(/** @type {Uint8Array} */ (c)))) + left.integrate(transaction, 0) + break + default: + if ($doc.check(c)) { + left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, createContentDocFromDoc(/** @type {Doc} */ (c))) + left.integrate(transaction, 0) + } else if (c instanceof YNode) { + left = new Item(createID(ownClientId, store.getClock(ownClientId)), left, left && left.lastId, right, right && right.id, parent, null, new ContentType(/** @type {any} */ (c))) + left.integrate(transaction, 0) + } else { + throw new Error('Unexpected content type in insert operation') + } + } + } + } + }) + packJsonContent() +} + +const lengthExceeded = () => error.create('Length exceeded!') + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {number} index + * @param {Array|Array|number|null|string|Uint8Array>} content + * + * @private + * @function + */ +export const typeListInsertGenerics = (transaction, parent, index, content) => { + if (index > parent._length) { + throw lengthExceeded() + } + if (index === 0) { + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, index, content.length) + } + return typeListInsertGenericsAfter(transaction, parent, null, content) + } + const startIndex = index + const marker = findMarker(parent, index) + let n = parent._start + if (marker !== null) { + n = marker.p + index -= marker.index + // we need to iterate one to the left so that the algorithm works + if (index === 0) { + // @todo refactor this as it actually doesn't consider formats + n = n.prev // important! get the left undeleted item so that we can actually decrease index + index += (n && n.countable && !n.deleted) ? n.length : 0 + } + } + for (; n !== null; n = n.right) { + if (!n.deleted && n.countable) { + if (index <= n.length) { + if (index < n.length) { + // insert in-between + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)) + } + break + } + index -= n.length + } + } + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, startIndex, content.length) + } + return typeListInsertGenericsAfter(transaction, parent, n, content) +} + +/** + * Pushing content is special as we generally want to push after the last item. So we don't have to update + * the search marker. + * + * @param {Transaction} transaction + * @param {YNode} parent + * @param {Array|Array|number|null|string|Uint8Array>} content + * + * @private + * @function + */ +export const typeListPushGenerics = (transaction, parent, content) => { + // Use the marker with the highest index and iterate to the right. + const marker = (parent._searchMarker || []).reduce((maxMarker, currMarker) => currMarker.index > maxMarker.index ? currMarker : maxMarker, { index: 0, p: parent._start }) + let n = marker.p + if (n) { + while (n.right) { + n = n.right + } + } + return typeListInsertGenericsAfter(transaction, parent, n, content) +} + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {number} index + * @param {number} length + * + * @private + * @function + */ +export const typeListDelete = (transaction, parent, index, length) => { + if (length === 0) { return } + const startIndex = index + const startLength = length + const marker = findMarker(parent, index) + let n = parent._start + if (marker !== null) { + n = marker.p + index -= marker.index + } + // compute the first item to be deleted + for (; n !== null && index > 0; n = n.right) { + if (!n.deleted && n.countable) { + if (index < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + index)) + } + index -= n.length + } + } + // delete all items until done + while (length > 0 && n !== null) { + if (!n.deleted) { + if (length < n.length) { + getItemCleanStart(transaction, createID(n.id.client, n.id.clock + length)) + } + n.delete(transaction) + length -= n.length + } + n = n.right + } + if (length > 0) { + throw lengthExceeded() + } + if (parent._searchMarker) { + updateMarkerChanges(parent._searchMarker, startIndex, -startLength + length /* in case we remove the above exception */) + } +} + +/** + * @todo inline this code + * + * @param {Transaction} transaction + * @param {YNode} parent + * @param {string} key + * + * @private + * @function + */ +export const nodeMapDelete = (transaction, parent, key) => { + const c = parent._map.get(key) + if (c !== undefined) { + c.delete(transaction) + } +} + +/** + * @param {Transaction} transaction + * @param {YNode} parent + * @param {string} key + * @param {YValue} value + * + * @private + * @function + */ +export const nodeMapSet = (transaction, parent, key, value) => { + const left = parent._map.get(key) || null + const doc = transaction.doc + const ownClientId = doc.clientID + let content + if (value == null) { + content = new ContentAny([value]) + } else { + switch (value.constructor) { + case Number: + case Object: + case Boolean: + case Array: + case String: + case Date: + case BigInt: + content = new ContentAny([value]) + break + case Uint8Array: + content = new ContentBinary(/** @type {Uint8Array} */ (value)) + break + default: + if ($doc.check(value)) { + content = createContentDocFromDoc(/** @type {Doc} */ (value)) + } else if (value instanceof YNode) { + content = new ContentType(/** @type {any} */ (value)) + } else { + throw new Error('Unexpected content type') + } + } + } + new Item(createID(ownClientId, doc.store.getClock(ownClientId)), left, left && left.lastId, null, null, parent, key, content).integrate(transaction, 0) +} + +/** + * @param {YNode} parent + * @param {string} key + * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined} + * + * @private + * @function + */ +export const nodeMapGet = (parent, key) => { + parent.doc ?? warnPrematureAccess() + const val = parent._map.get(key) + return val !== undefined && !val.deleted ? val.content.getContent()[val.length - 1] : undefined +} + +/** + * @param {YNode} parent + * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined>} + * + * @private + * @function + */ +export const nodeMapGetAll = (parent) => { + /** + * @type {Object} + */ + const res = {} + parent.doc ?? warnPrematureAccess() + parent._map.forEach((value, key) => { + if (!value.deleted) { + res[key] = value.content.getContent()[value.length - 1] + } + }) + return res +} + +/** + * @todo move this to getContent/getDelta + * + * Render the difference to another ydoc (which can be empty) and highlight the differences with + * attributions. + * + * Note that deleted content that was not deleted in prevYdoc is rendered as an insertion with the + * attribution `{ isDeleted: true, .. }`. + * + * @template {delta.DeltaBuilderAny} TypeDelta + * @param {TypeDelta} d + * @param {YNode} parent + * @param {Set?} attrsToRender + * @param {AbstractRenderer?} renderer + * @param {boolean} deep + * @param {Set|Map|null} [modified] - set of types that should be rendered as modified children + * @param {IdSet?} [itemsToRender] + * @param {any} [opts] + * @param {any} [optsAll] + * + * @private + * @function + */ +export const nodeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modified, itemsToRender, opts, optsAll) => { + // @todo support modified ops! + /** + * @param {Item} item + * @param {string} key + */ + const renderAttrs = (item, key) => { + /** + * @type {{ deleted: boolean, attrs: Array>?, content: AbstractContent }} + */ + let piece + if (renderer === null || !renderer.hasItem(item)) { + // generic fast path — the item's own fields, no renderer involved + piece = { deleted: item.deleted, attrs: null, content: item.content } + } else { + /** + * @type {Array>} + */ + const cs = [] + renderer.readContent(cs, item.id.client, item.id.clock, item.deleted, item.content, 1) + if (cs.length === 0) return // the renderer surfaces nothing for this attribute (e.g. a diff renderer hiding an unchanged delete) + piece = cs[cs.length - 1] + } + const { deleted, attrs, content } = piece + const attribution = createAttributionFromAttributionItems(attrs, deleted) + let c = array.last(content.getContent()) + if (deleted) { + if (attribution != null) { + // Item surfaced under attribution (suggestion view / diff renderer, either in snapshot mode + // or in an event-driven render). The attribute is still observable in the rendered state, so + // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content + // children are rendered for the same case (positive `InsertOp` with attribution, never + // `DeleteOp`). + // also re-emit when the change happened *inside* the still-rendered value (`modified` + // contains the value type but the attr's own map item is not part of the change) — the + // deleted value has no modifyAttr path, and a full-state `setAttr` replace is idempotent + if (itemsToRender == null || itemsToRender.hasId(item.lastId) || (c instanceof YNode && modified != null && modified.has(c))) { + if (deep && c instanceof YNode) { + // full-state value render: a positive `setAttr` *replaces* the attr value on the + // consuming side, so the nested type must render as its full attributed state + // (change-scoped opts like `itemsToRender` would render bare retains here) + c = /** @type {any} */(c).toDelta({ renderer, deep: true }) + } + d.setAttr(key, c, attribution) + } + } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) { + // Hard-deleted attribute within a change render: emit the `deleteAttr` op so consumers (the + // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In + // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders + // run with `render === false` for such items, so nothing was emitted before either). + d.deleteAttr(key, attribution) + } + } else if (deep && c instanceof YNode && modified?.has(c)) { + d.modifyAttr(key, c.toDelta(opts)) + } else { + if (deep && c instanceof YNode) { + c = /** @type {any} */(c).toDelta(optsAll) + } + d.setAttr(key, c, attribution) + } + } + if (attrsToRender == null) { + parent._map.forEach(renderAttrs) + } else { + attrsToRender.forEach(key => key != null && renderAttrs(/** @type {Item} */ (parent._map.get(key)), key)) + } +} + +/** + * @param {YNode} parent + * @param {string} key + * @return {boolean} + * + * @private + * @function + */ +export const nodeMapHas = (parent, key) => { + parent.doc ?? warnPrematureAccess() + const val = parent._map.get(key) + return val !== undefined && !val.deleted +} + +/** + * @param {Item} item + * @param {Snapshot|undefined} snapshot + * + * @protected + * @function + */ +export const isVisible = (item, snapshot) => snapshot === undefined + ? !item.deleted + : snapshot.sv.has(item.id.client) && (snapshot.sv.get(item.id.client) || 0) > item.id.clock && !snapshot.ds.hasId(item.id) + +/** + * @param {YNode} parent + * @param {string} key + * @param {Snapshot} snapshot + * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined} + * + * @private + * @function + */ +export const nodeMapGetSnapshot = (parent, key, snapshot) => { + let v = parent._map.get(key) || null + while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) { + v = v.left + } + return v !== null && isVisible(v, snapshot) ? v.content.getContent()[v.length - 1] : undefined +} + +/** + * @param {YNode} parent + * @param {Snapshot} snapshot + * @return {Object|number|null|Array|string|Uint8Array|YNode|undefined>} + * + * @private + * @function + */ +export const nodeMapGetAllSnapshot = (parent, snapshot) => { + /** + * @type {Object} + */ + const res = {} + parent._map.forEach((value, key) => { + /** + * @type {Item|null} + */ + let v = value + while (v !== null && (!snapshot.sv.has(v.id.client) || v.id.clock >= (snapshot.sv.get(v.id.client) || 0))) { + v = v.left + } + if (v !== null && isVisible(v, snapshot)) { + res[key] = v.content.getContent()[v.length - 1] + } + }) + return res +} + +/** + * @param {YNode & { _map: Map }} type + * @return {IterableIterator>} + * + * @private + * @function + */ +export const createMapIterator = type => { + type.doc ?? warnPrematureAccess() + return iterator.iteratorFilter(type._map.entries(), /** @param {any} entry */ entry => !entry[1].deleted) +} + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentType} + */ +export const readContentType = decoder => new ContentType(readYNode(decoder)) + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentString} + */ +export const readContentString = decoder => new ContentString(decoder.readString()) + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentJSON} + */ +export const readContentJSON = decoder => { + const len = decoder.readLen() + const cs = [] + for (let i = 0; i < len; i++) { + const c = decoder.readString() + if (c === 'undefined') { + cs.push(undefined) + } else { + cs.push(JSON.parse(c)) + } + } + return new ContentJSON(cs) +} + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentFormat} + */ +export const readContentFormat = decoder => new ContentFormat(decoder.readKey(), decoder.readJSON()) + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentEmbed} + */ +export const readContentEmbed = decoder => new ContentEmbed(decoder.readJSON()) + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentDoc} + */ +export const readContentDoc = decoder => new ContentDoc(decoder.readString(), decoder.readAny()) + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {ContentAny} + */ +export const readContentAny = decoder => { + const len = decoder.readLen() + const cs = [] + for (let i = 0; i < len; i++) { + cs.push(decoder.readAny()) + } + return new ContentAny(cs) +} + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder + * @return {ContentBinary} + */ +export const readContentBinary = decoder => new ContentBinary(decoder.readBuf()) + +/** + * @private + * + * @param {UpdateDecoderV1 | UpdateDecoderV2 } decoder + * @return {ContentDeleted} + */ +export const readContentDeleted = decoder => new ContentDeleted(decoder.readLen()) + +/** + * A lookup map for reading Item content. + * + * @type {Array} + */ +export const contentRefs = [ + () => { error.unexpectedCase() }, // GC is not ItemContent + readContentDeleted, // 1 + readContentJSON, // 2 + readContentBinary, // 3 + readContentString, // 4 + readContentEmbed, // 5 + readContentFormat, // 6 + readContentType, // 7 + readContentAny, // 8 + readContentDoc, // 9 + () => { error.unexpectedCase() } // 10 - Skip is not ItemContent +] + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @param {number} info + */ +export const readItemContent = (decoder, info) => contentRefs[info & binary.BITS5](decoder) + +/** + * @param {UpdateDecoderV1 | UpdateDecoderV2} decoder + * @return {YNode} + * + * @private + * @function + */ +export const readYNode = decoder => { + const typeRef = decoder.readTypeRef() + const ynode = new YNode(typeRef === YXmlElementRefID || typeRef === YXmlHookRefID ? decoder.readKey() : null) + ynode._legacyTypeRef = typeRef + return ynode +} diff --git a/test.html b/test.html new file mode 100644 index 0000000..a486a4b --- /dev/null +++ b/test.html @@ -0,0 +1,176 @@ + + + + + Testing @y/y + + + + + + + diff --git a/tests/IdMap.tests.js b/tests/IdMap.tests.js new file mode 100644 index 0000000..409caba --- /dev/null +++ b/tests/IdMap.tests.js @@ -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]>} 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>} + */ + 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?} + */ + const slice1 = ids1.slice(client, clock, 1)[0].attrs + /** + * @type {Array?} + */ + const slice2 = ids2.slice(client, clock, 1)[0].attrs + /** + * @type {Array?} + */ + 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') + }) +} diff --git a/tests/IdSet.tests.js b/tests/IdSet.tests.js new file mode 100644 index 0000000..802b109 --- /dev/null +++ b/tests/IdSet.tests.js @@ -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} + */ + 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} */ (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) +} diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js new file mode 100644 index 0000000..0bfa301 --- /dev/null +++ b/tests/attribution.tests.js @@ -0,0 +1,2348 @@ +/** + * Testing if encoding/decoding compatibility and integration compatibility is given. + * We expect that the document always looks the same, even if we upgrade the integration algorithm, or add additional encoding approaches. + * + * The v1 documents were generated with Yjs v13.2.0 based on the randomisized tests. + */ + +import * as Y from '../src/index.js' +import * as t from 'lib0/testing' +import * as delta from 'lib0/delta' +import * as prng from 'lib0/prng' +import * as math from 'lib0/math' +import { bind, $rdt } from 'lib0/delta/rdt' +import { writeStructsFromIdSet } from '../src/utils/encoding-helpers.js' +import { init } from './testHelper.js' // eslint-disable-line + +/** + * @param {t.TestCase} _tc + */ +export const testRelativePositions = _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 rel = Y.createRelativePositionFromTypeIndex(ytext, 9, 1, renderer) // pos after "hello wo" + const abs1 = Y.createAbsolutePositionFromRelativePosition(rel, ydoc, true, renderer) + const abs2 = Y.createAbsolutePositionFromRelativePosition(rel, ydoc, true) + t.assert(abs1?.index === 9) + t.assert(abs2?.index === 3) +} + +/** + * @param {t.TestCase} _tc + */ +export const testAttributedEvents = _tc => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { + ytext.delete(6, 5) + }) + const renderer = Y.createDiffRenderer(v1, ydoc) + const c1 = ytext.toDelta({ renderer }) + t.compare(c1, delta.create().insert('hello ').insert('world', null, { delete: [] }).done()) + let calledObserver = false + ytext.observe(event => { + const d = event.getDelta({ renderer }) + t.compare(d, delta.create().retain(11).insert('!', null, { insert: [] }).done()) + calledObserver = true + }) + ytext.applyDelta(delta.create().retain(11).insert('!').done(), null, { renderer }) + t.assert(calledObserver) +} + +/** + * @param {t.TestCase} _tc + */ +export const testInsertionsMindingAttributedContent = _tc => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { + ytext.delete(6, 5) + }) + const renderer = Y.createDiffRenderer(v1, ydoc) + const c1 = ytext.toDelta({ renderer }) + t.compare(c1, delta.create().insert('hello ').insert('world', null, { delete: [] }).done()) + ytext.applyDelta(delta.create().retain(11).insert('content').done(), null, { renderer }) + t.assert(ytext.toString() === 'hello content') +} + +/** + * @param {t.TestCase} _tc + */ +export const testInsertionsIntoAttributedContent = _tc => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + ytext.insert(0, 'hello ') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { + ytext.insert(6, 'word') + }) + const renderer = Y.createDiffRenderer(v1, ydoc) + const c1 = ytext.toDelta({ renderer }) + t.compare(c1, delta.create().insert('hello ').insert('word', null, { insert: [] }).done()) + ytext.applyDelta(delta.create().retain(9).insert('l').done(), null, { renderer }) + t.assert(ytext.toString() === 'hello world') +} + +export const testYdocDiff = () => { + const ydocStart = new Y.Doc() + ydocStart.get('text').insert(0, 'hello') + ydocStart.get('array').insert(0, [1, 2, 3]) + ydocStart.get('map').setAttr('k', 42) + ydocStart.get('map').setAttr('nested', new Y.Node()) + const ydocUpdated = Y.cloneDoc(ydocStart) + ydocUpdated.get('text').insert(5, ' world') + ydocUpdated.get('array').insert(1, ['x']) + ydocUpdated.get('map').setAttr('newk', 42) + ydocUpdated.get('map').getAttr('nested').insert(0, [1]) + // @todo add custom attribution + const d = Y.diffDocsToDelta(ydocStart, ydocUpdated).done() + console.log('calculated diff', d.toJSON()) + const expected = delta.create() + .modifyAttr('text', delta.create().retain(5).insert(' world', null, { insert: [] })) + .modifyAttr('array', delta.create().retain(1).insert(['x'], null, { insert: [] })) + .modifyAttr('map', delta.create().setAttr('newk', 42, { insert: [] }).modifyAttr('nested', delta.create().insert([1], null, { insert: [] }))) + const expectedDone = expected.done() + t.compare(d, expectedDone) +} + +export const testChildListContent = () => { + const ydocStart = new Y.Doc() + const ydocUpdated = Y.cloneDoc(ydocStart) + const yf = new Y.Node('test') + let calledEvent = 0 + yf.applyDelta(delta.create().insert('test content').setAttr('k', 'v').done()) + + const yarray = ydocUpdated.get('array') + yarray.observeDeep(event => { + calledEvent++ + const d = event.deltaDeep + const expectedD = delta.create().insert([delta.create('test').insert('test content').setAttr('k', 'v')]) + t.compare(d, expectedD) + }) + ydocUpdated.get('array').insert(0, [yf]) + t.assert(calledEvent === 1) + const d = Y.diffDocsToDelta(ydocStart, ydocUpdated) + console.log('calculated diff', d.toJSON()) + const expected = delta.create() + .modifyAttr('array', delta.create().insert([delta.create('test').insert('test content', null, { insert: [] }).setAttr('k', 'v', { insert: [] })], null, { insert: [] }).done()) + t.compare(d.done(), expected.done()) +} + +/** + * @param {t.TestCase} tc + */ +export const testAttributionSession1 = tc => { + const { testConnector, users, text0, text1 } = init(tc, { users: 3 }) + users[0].gc = false + const globalAttributions = Y.createContentMap() + const v1 = Y.cloneDoc(users[0]) + users.forEach(user => user.on('update', (update, _, ydoc, tr) => { + if (!tr.local) return + const userid = ydoc.clientID.toString() + const contentIds = Y.createContentIdsFromUpdate(update) + Y.insertIntoIdMap(globalAttributions.inserts, Y.createIdMapFromIdSet(contentIds.inserts, [Y.createContentAttribute('insert', userid)])) + Y.insertIntoIdMap(globalAttributions.deletes, Y.createIdMapFromIdSet(contentIds.deletes, [Y.createContentAttribute('delete', userid)])) + })) + text0.insert(0, 'a') + text1.insert(0, 'b') + testConnector.flushAllMessages() + const d1 = text0.toDelta({ renderer: Y.createDiffRenderer(v1, users[0], { attributions: globalAttributions }) }) + t.compare(d1, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { insert: ['1'] }).done()) + const v2 = Y.cloneDoc(users[0]) + text0.delete(1, 1) + text1.insert(2, 'c') + testConnector.flushAllMessages() + const d2 = text0.toDelta({ renderer: Y.createDiffRenderer(v2, users[0], { attributions: globalAttributions }) }) + t.compare(d2, delta.create().insert('a').insert('b', null, { delete: ['0'] }).insert('c', null, { insert: ['1'] }).done()) + + const onlyUser0ChangesAttributed = Y.createContentMap( + Y.filterIdMap(globalAttributions.inserts, attrs => attrs.some(attr => attr.name === 'insert' && attr.val === '0')), + Y.filterIdMap(globalAttributions.deletes, attrs => attrs.some(attr => attr.name === 'delete' && attr.val === '0')) + ) + const rendererUser0 = new Y.AttributionsRenderer(onlyUser0ChangesAttributed) + const d3 = text0.toDelta({ renderer: rendererUser0 }) + t.compare(d3, delta.create().insert('a', null, { insert: ['0'] }).insert('b', null, { delete: ['0'] }).insert('c').done()) + Y.undoContentIds(users[0], Y.createContentIdsFromContentMap(onlyUser0ChangesAttributed)) + + const d4 = text0.toDelta() + t.compare(d4, delta.create().insert('bc').done()) +} + +export const testAttributionEvent = () => { + const ydoc = new Y.Doc() + const ynode = ydoc.get() + //

hi

+ ynode.applyDelta(delta.create().insert([delta.create('p').insert('hi').done()]).done()) + const ydocBase = Y.cloneDoc(ydoc) + const renderer = Y.createDiffRenderer(ydocBase, ydoc) + let called = false + ynode.observeDeep(event => { + const change = event.getDelta({ renderer }) + const expectedChange = delta.create().modify(delta.create('p').retain(2, null, { delete: [] }), null, { delete: [] }).done() + t.compare( + change, + expectedChange + ) + called = true + }) + // delete

+ // we expect that the children get attributions as well + ynode.delete(0, 1) + t.assert(called) +} + +export const testAttributionChange = () => { + const ydoc = new Y.Doc() + const ynode = ydoc.get() + ynode.applyDelta(delta.create().insert('hi').done()) + const ydocClone = Y.cloneDoc(ydoc) + const renderer = Y.createDiffRenderer(ydocClone, ydoc) + ynode.applyDelta(delta.create().retain(2).insert('!').done()) + let calledHandler = false + renderer.on('change', changes => { + calledHandler = true + const changeUpdate = ynode.toDelta({ renderer, deep: true, itemsToRender: changes, retainInserts: true, retainDeletes: true }) + // the '!' lost its `{ insert: [] }` suggestion attribution → the change clears it (tri-state `null`) + const expectedUpdate = delta.create().retain(2).retain(1, undefined, null) + t.compare(changeUpdate, expectedUpdate) + }) + Y.applyUpdate(ydocClone, Y.encodeStateAsUpdate(ydoc)) + t.assert(calledHandler) +} + +/** + * A YNode implements the lib0 `RDT` interface, so two types can be kept in sync with `bind`. + */ +export const testRdtBinding = () => { + const docA = new Y.Doc() + const docB = new Y.Doc() + const a = docA.get('text') + const b = docB.get('text') + const binding = bind(a, b) + // edit A -> propagates to B + a.insert(0, 'hello') + t.assert(b.toString() === 'hello') + // edit B -> propagates back to A (no echo loop) + b.insert(5, ' world') + t.assert(a.toString() === 'hello world') + t.assert(b.toString() === 'hello world') + // after the binding is destroyed, changes no longer propagate + binding.destroy() + a.insert(0, 'x') + t.assert(a.toString() === 'xhello world') + t.assert(b.toString() === 'hello world') +} + +/** + * Local changes are emitted on the `'delta'` channel as the deep delta. + */ +export const testRdtDeltaEvent = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + /** + * @type {any} + */ + let captured = null + ytext.on('delta', d => { captured = d }) + ytext.insert(0, 'hello') + t.compare(captured, delta.create().insert('hello').done()) +} + +/** + * The `'delta'` event carries the transaction origin as its second argument. + */ +export const testRdtDeltaEventOrigin = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + /** + * @type {any} + */ + let capturedOrigin = null + ytext.on('delta', (_d, origin) => { capturedOrigin = origin }) + const myOrigin = {} + ydoc.transact(() => { + ytext.insert(0, 'hello') + }, myOrigin) + t.assert(capturedOrigin === myOrigin) + // without an explicit origin, `null` is emitted + capturedOrigin = myOrigin + ytext.insert(5, ' world') + t.assert(capturedOrigin === null) + // the origin passed to `applyDelta` becomes the transaction origin and is forwarded on the event + const applyOrigin = {} + ytext.applyDelta(delta.create().retain(11).insert('!').done(), applyOrigin) + t.assert(capturedOrigin === applyOrigin) + // `applyDelta` without an explicit origin emits `null` + capturedOrigin = applyOrigin + ytext.applyDelta(delta.create().retain(12).insert('?').done()) + t.assert(capturedOrigin === null) +} + +/** + * `useRenderer` changes the default renderer used by `toDelta` (and friends). Calling `toDelta()` + * with no argument afterwards is equivalent to passing the renderer explicitly. + */ +export const testUseRenderer = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get() + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { + ytext.delete(6, 5) + }) + const renderer = Y.createDiffRenderer(v1, ydoc) + const explicit = ytext.toDelta({ renderer }) + // change the default renderer; toDelta() with no arg now matches the explicit form + ytext.useRenderer(renderer) + const viaDefault = ytext.toDelta() + t.compare(viaDefault, explicit) + t.compare(viaDefault, delta.create().insert('hello ').insert('world', null, { delete: [] }).done()) +} + +/** + * `destroy()` emits the RDT `'destroy'` event, and top-level types are destroyed with their Doc. + */ +export const testRdtDestroy = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + let destroyed = 0 + ytext.on('destroy', () => { destroyed++ }) + ytext.destroy() + t.assert(destroyed === 1) + // a top-level type is torn down when its Doc is destroyed + const ydoc2 = new Y.Doc() + const ytext2 = ydoc2.get('text') + let destroyed2 = 0 + ytext2.on('destroy', () => { destroyed2++ }) + ydoc2.destroy() + t.assert(destroyed2 === 1) +} + +/** + * The `'delta'` event bubbles to ancestors on nested changes, like `observeDeep`. A listener on a + * container fires (with the container-rooted delta) when a nested child is edited. + */ +export const testRdtDeltaBubblesLikeObserveDeep = () => { + const ydoc = new Y.Doc() + const yarray = ydoc.get('arr') + const child = new Y.Node() + yarray.insert(0, [child]) + let containerFired = 0 + let childFired = 0 + /** + * @type {any} + */ + let captured = null + yarray.on('delta', d => { containerFired++; captured = d }) + child.on('delta', () => { childFired++ }) + child.insert(0, 'hi') + // both the edited child and its ancestor container received a 'delta' + t.assert(childFired === 1) + t.assert(containerFired === 1) + // the container-rooted delta is a non-empty (nested modify) change + t.assert(captured !== null && !captured.isEmpty()) +} + +/** + * `get delta()` returns the deep delta and keeps it current on every event of this type, including + * nested-child edits (which apply as a nested `modify`). The returned value is the live cache. + */ +export const testRdtDeltaCacheMaintenance = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello') + // first access materializes the cache + t.assert(ytext.delta.equals(delta.create().insert('hello').done())) + // a later edit updates the live cache in place + const live = ytext.delta + ytext.insert(5, ' world') + t.assert(live === ytext.delta) // same maintained object + t.assert(ytext.delta.equals(delta.create().insert('hello world').done())) + t.assert(ytext.delta.equals(ytext.toDeltaDeep())) // matches a fresh deep render + + // nested: editing a child updates the container's cached deep delta via a nested modify apply + const yarray = ydoc.get('arr') + const child = new Y.Node() + yarray.insert(0, [child]) + child.insert(0, 'a') + const before = yarray.delta // materialize under base renderer + child.insert(1, 'b') // nested edit after materialization + t.assert(before === yarray.delta) + t.assert(yarray.delta.equals(yarray.toDeltaDeep())) +} + +/** + * `clearCache()` drops the maintained deep delta; the next `delta` access re-materializes it. + */ +export const testRdtClearCache = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello') + const d1 = ytext.delta + t.assert(ytext._delta !== null) + ytext.clearCache() + t.assert(ytext._delta === null) + const d2 = ytext.delta // re-materialized, a fresh builder + t.assert(d2 !== d1) + t.assert(d2.equals(delta.create().insert('hello').done())) +} + +/** + * `useRenderer` re-renders the maintained delta with the new renderer, emits the difference on the + * `'delta'` channel, and updates the cache. + */ +export const testRdtUseRendererEmitsDiff = () => { + const ydoc = new Y.Doc() + const ytext = ydoc.get('text') + ytext.insert(0, 'hello world') + const v1 = Y.cloneDoc(ydoc) + ydoc.transact(() => { ytext.delete(6, 5) }) + // materialize the cache under the base renderer + t.assert(ytext.delta.equals(delta.create().insert('hello ').done())) + /** + * @type {any} + */ + let captured = null + ytext.on('delta', d => { captured = d }) + ytext.useRenderer(Y.createDiffRenderer(v1, ydoc)) + // a non-empty rendering diff was emitted only on the 'delta' channel + t.assert(captured !== null && !captured.isEmpty()) + // and the cache now reflects the diff-rendered state + t.assert(ytext.delta.equals(delta.create().insert('hello ').insert('world', null, { delete: [] }).done())) +} + +/** + * `YNode` conforms to the lib0 `RDT` interface — verified at runtime with `$rdt.check` (replaces the + * old compile-time `_assertYNodeIsRdt`). + */ +export const testRdtConformsToRdtSchema = () => { + t.assert($rdt.check(new Y.Doc().get())) + t.assert($rdt.check(new Y.Node())) + t.assert(!$rdt.check({})) + t.assert(!$rdt.check(null)) +} + +/** + * Collect a type and all of its (non-deleted) nested `YNode` descendants. + * + * @param {Y.Node} root + * @return {Array>} + */ +const collectTypes = root => { + /** + * @type {Array>} + */ + const out = [root] + for (let i = 0; i < out.length; i++) { + out[i].forEach(c => { if (c instanceof Y.Node) out.push(c) }) + out[i].forEachAttr(v => { if (v instanceof Y.Node) out.push(v) }) + } + return out +} + +/** + * Apply one random mutation to a random type in the tree rooted at `root`. + * + * @param {prng.PRNG} gen + * @param {Y.Node} root + */ +const applyRandomYNodeOp = (gen, root) => { + const target = prng.oneOf(gen, collectTypes(root)) + switch (prng.int32(gen, 0, 4)) { + case 0: // insert text + target.insert(prng.int32(gen, 0, target.length), prng.word(gen)) + break + case 1: // insert a nested type + target.insert(prng.int32(gen, 0, target.length), [new Y.Node()]) + break + case 2: // delete a range + if (target.length > 0) { + const p = prng.int32(gen, 0, target.length - 1) + target.delete(p, prng.int32(gen, 1, math.min(3, target.length - p))) + } + break + case 3: // format a range (add or remove bold) + if (target.length > 0) { + const p = prng.int32(gen, 0, target.length - 1) + target.format(p, prng.int32(gen, 1, math.min(3, target.length - p)), { bold: prng.bool(gen) ? true : null }) + } + break + case 4: // set / delete a map attribute + if (prng.bool(gen)) { + target.setAttr(prng.oneOf(gen, ['a', 'b', 'c']), prng.word(gen)) + } else { + target.deleteAttr(prng.oneOf(gen, ['a', 'b', 'c'])) + } + break + } +} + +/** + * Assert the maintained `.delta` cache of `type` is (a) structurally correct — equals a fresh + * `toDelta({ deep: true })` — and (b) fingerprint-memo-fresh — its (possibly memoized) + * `fingerprint` equals a `delta.cloneDeep` recompute (cloneDeep rebuilds every node memo-free, + * so it is a true recompute oracle at every depth; `clone`/`slice` are not — they share frozen + * nested deltas by reference). + * + * Reading `.fingerprint` also (re)memoizes it on the LIVE cache at every depth — exactly what a + * y-sync-style consumer does when diffing against `type.delta` — so the NEXT in-place cache patch + * hits an already-fingerprinted tree and any missing memo invalidation surfaces on the next call. + * + * Diagnosis: `equals` fail = cache drift; `equals` pass + fingerprint mismatch = stale memo + * (a mutation reached the cache without routing through the lib0 builder API — see the + * `YNode._delta` invariant in src/ynode.js). + * + * @param {Y.Node} type + * @param {string} msg + */ +const assertDeltaCacheFresh = (type, msg) => { + const cached = type.delta + const memoFp = cached.fingerprint // reads (and re-populates) the memo on the live cache + const fresh = type.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error(`${msg} cached :`, JSON.stringify(cached.toJSON())) + console.error(`${msg} toDelta:`, JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), `${msg}: maintained .delta drifted from toDelta({ deep: true })`) + t.assert(memoFp === delta.cloneDeep(/** @type {any} */ (cached)).fingerprint, + `${msg}: stale fingerprint memo on the live .delta cache`) +} + +/** + * Fuzz: after each random mutation, every type's maintained `delta` cache (at every nesting level) + * must equal a fresh deep render `toDelta({ deep: true })`. + * + * @param {t.TestCase} tc + */ +export const testRdtDeltaFuzz = tc => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + for (let i = 0; i < 300; i++) { + applyRandomYNodeOp(tc.prng, root) + collectTypes(root).forEach(type => + t.assert(type.delta.equals(type.toDelta({ deep: true })), `iter ${i}`)) + } +} + +/** + * Fuzz pin: fingerprint-memo safety of the maintained `.delta` cache under in-place patching + * (the per-transaction `type._delta?.apply(change)` path in `cleanupTransactions`). + * Reading `.fingerprint` each iteration memoizes on the live cache of every type (root deep + * cache AND each nested type's own cache); the next random transaction then patches + * already-fingerprinted trees, and the memo must equal a full `delta.cloneDeep` recompute. + * `testRdtDeltaFuzz` above is the memo-free control: if only this test fails, the drift is a + * stale fingerprint memo, not a structural one. + * + * @param {t.TestCase} tc + */ +export const testRdtFingerprintMemoFuzzCacheDrift = tc => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + for (let i = 0; i < 300; i++) { + applyRandomYNodeOp(tc.prng, root) + collectTypes(root).forEach(type => assertDeltaCacheFresh(type, `iter ${i}`)) + } +} + +/** + * Fuzz under a diffing renderer, across two synced replicas. Each replica is a "suggestion doc" that + * diffs against its own fixed baseline clone (taken after some shared initial content). With the plain + * diff renderer (no `attrs`), suggestion inserts render `{ insert: [] }` and deletes render + * `{ delete: [] }` — identical on every replica — so the maintained, diff-attributed `delta` must + * converge across replicas (and match a fresh deep render). The cache is kept current purely by the + * `'delta'` event (no recompute). Also memo-pressures both replicas' live caches via fingerprint + * reads each iteration (see `assertDeltaCacheFresh`) — these consume no prng, so seeds reproduce. + * + * @param {t.TestCase} tc + */ +export const testRdtDeltaSuggestionConvergence = tc => { + const { testConnector, users } = init(tc, { users: 2 }) + const [d0, d1] = users + d0.get('root').insert(0, 'shared baseline content') + testConnector.flushAllMessages() + // each replica diffs against its own fixed baseline clone (plain diff renderer => {insert:[]}/{delete:[]}) + d0.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d0), d0)) + d1.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d1), d1)) + for (let i = 0; i < 300; i++) { + applyRandomYNodeOp(tc.prng, prng.oneOf(tc.prng, users).get('root')) // includes format add/remove + testConnector.flushAllMessages() + const a = d0.get('root').delta + const b = d1.get('root').delta + t.assert(a.equals(b), `converge iter ${i}`) // the suggestion view is replica-independent + // matches a fresh render, and the fingerprint memos survive the in-place cache patches + assertDeltaCacheFresh(d0.get('root'), `iter ${i} (d0)`) + assertDeltaCacheFresh(d1.get('root'), `iter ${i} (d1)`) + } +} + +/** + * Regression (deterministic, seed 1): removing a format under a diffing renderer must keep the + * incrementally-maintained `.delta` equal to a fresh `toDelta({ deep: true })`. The bug was in the + * `ContentFormat` change-mode block of `toDelta` (src/ynode.js): un-formatting cleared the format + * *value* but emitted only a context-skip for the format-*attribution*, so the maintained cache kept + * a stale `{attribution:{format:{bold:[]}}}` on the un-formatted range and drifted (at iter 35). The + * fix emits an explicit `attribution:{format:{:null}}` clear on the retained range (only in a + * change/diff render). This test pins that behavior; if it regresses, the drift reappears at iter 35. + */ +export const testRdtFormatRemovalDrift = () => { + const gen = prng.create(1) // fixed seed → deterministic + const docs = [new Y.Doc(), new Y.Doc()] + const [d0, d1] = docs + const sync = () => { + Y.applyUpdate(d1, Y.encodeStateAsUpdate(d0, Y.encodeStateVector(d1))) + Y.applyUpdate(d0, Y.encodeStateAsUpdate(d1, Y.encodeStateVector(d0))) + } + d0.get('root').insert(0, 'shared baseline content') + sync() + // each replica diffs against its own fixed baseline clone + d0.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d0), d0)) + d1.get('root').useRenderer(Y.createDiffRenderer(Y.cloneDoc(d1), d1)) + for (let i = 0; i < 40; i++) { + applyRandomYNodeOp(gen, prng.oneOf(gen, docs).get('root')) + sync() + // read `.delta` every step so it is maintained incrementally (a single read at the end would + // recompute fresh and hide the drift). The maintained delta MUST equal a fresh deep render. + const cached = d0.get('root').delta + const fresh = d0.get('root').toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('iter ' + i + ' cached :', JSON.stringify(cached.toJSON())) + console.error('iter ' + i + ' toDelta:', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), `iter ${i}: maintained .delta drifted from toDelta({ deep: true })`) + } +} + +/** + * Regression (minimal, deterministic, no prng): re-bolding content by deleting a transient `bold:null` + * marker under a diffing renderer must keep the maintained `delta` equal to a fresh deep render. + * + * Steps: bold all of "abcdef", un-bold "cd" (inserts a `bold:null` marker), then re-bold "cd" (which + * DELETES that marker). The deleted marker surfaces `attrs == null` in the change render, so the + * attribution context must be *preserved* (not cleared) for the re-bolded run; a fresh render sees the + * resulting attributed `bold:true` marker and renders `{format:{bold:[]}}`, so the cache must match: + * + * .delta == toDelta({deep}) == "abcdef"{bold, attr:{format:{bold:[]}}} + */ +export const testRdtFormatRebold = () => { + const doc = new Y.Doc() + const root = doc.get('root') + root.insert(0, 'abcdef') + // diff against a baseline taken BEFORE formatting => every format change is an attributed suggestion + root.useRenderer(Y.createDiffRenderer(Y.cloneDoc(doc), doc)) + // first access starts maintaining the incremental cache (baseline == current, so no suggestions yet) + t.assert(root.delta.equals(delta.create().insert('abcdef').done())) + root.format(0, 6, { bold: true }) // all bold + root.format(2, 2, { bold: null }) // un-bold "cd" (inserts a transient bold:null marker) + root.format(2, 2, { bold: true }) // re-bold "cd" => DELETES that transient marker + const cached = root.delta + const fresh = root.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('rebold cached :', JSON.stringify(cached.toJSON())) + console.error('rebold toDelta:', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta drifted from toDelta({ deep: true }) after re-bold') +} + +/** + * Regression (was a known bug — minimal, deterministic, no prng): inserting an embed (nested `Y.Node`) + * into a bold run used to leave a spurious `attribution:{format:{bold:null}}` null-leaf on the embed in + * the maintained `delta`, where a fresh deep render has none. Now fixed; this pins it. + * + * Two ops: bold "ab", then insert an embed between "a" and "b". Inserting into a formatted run makes + * Yjs surround the embed with NEGATED markers (`[bold:null] [bold:true]`) so the embed is not + * bold. In the change render of that insert, the new `bold:null` negation marker triggers the + * format-attribution clear (a `null` leaf), and because the embed is a FRESH renderContent insert it + * inherits that leaf — but unlike a text insert the null-leaf does NOT resolve away for a `ContentType` + * (embed) insert, so it sticks in the cache. A full render (insert mode) never emits the leaf: + * + * maintained .delta : "a"{bold,attr} | {attr:{format:{bold:null}, insert:[]}} | "b"{bold,attr} + * toDelta({deep}) : "a"{bold,attr} | {attr:{insert:[]}} | "b"{bold,attr} + * + * Root cause: the single `usedAttribution` context can't distinguish inserts (need absolute attribution, + * no null-leaves) from retains (need the null-leaf clear) — the value dimension already splits these + * (`currentFormats` for inserts vs `changedFormats` for retains); the attribution dimension does + * not. (Note: a *third* op `format(1,1,{bold:null})` to un-bold the embed is a no-op — the embed is + * already not bold — so it produces an empty transaction and fires no `'delta'` event.) + */ +export const testRdtFormatEmbedInBold = () => { + const doc = new Y.Doc() + const root = doc.get('root') + root.insert(0, 'ab') + root.useRenderer(Y.createDiffRenderer(Y.cloneDoc(doc), doc)) + // first access starts maintaining the incremental cache (baseline == current, so no suggestions yet) + t.assert(root.delta.equals(delta.create().insert('ab').done())) + root.format(0, 2, { bold: true }) // bold "ab" + root.insert(1, [new Y.Node()]) // insert an embed inside the bold run: "ab" + const cached = root.delta + const fresh = root.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('embed-in-bold cached :', JSON.stringify(cached.toJSON())) + console.error('embed-in-bold toDelta:', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta drifted from toDelta({ deep: true }) after embed-in-bold insert') +} + +/** + * Regression (minimal, deterministic, no prng): formatting a char and then deleting it under a diffing + * renderer used to leave the maintained `delta` with a stale bold value + `{format:{bold:[]}}` + * attribution on the deleted char, where a fresh deep render keeps only the `{delete:[]}` suggestion. + * + * maintained .delta : "a" { format:{bold:true}, attribution:{ format:{bold:[]}, delete:[] } } (was) + * toDelta({deep}) : "a" { attribution:{ delete:[] } } (correct) + * + * On delete, the format markers around the char are cleaned up (deleted) too; their insert+delete + * suggestion nets to no attribution, so the change render skipped them and never undid the value + + * attribution the format step had written to the cache. The fix (in `toDelta`): a retain emits the + * format *diff* (`changedFormats`, which carries the `bold→null` clear), and a deleted format marker + * that actually removes a format under an attributing renderer emits an explicit `{format:{:null}}` + * attribution clear. This was the stale-`{format:{bold:[]}}`-on-deleted-content (re-assert) class. + */ +export const testRdtFormatDeleteFormatted = () => { + const doc = new Y.Doc() + const root = doc.get('root') + root.insert(0, 'a') + root.useRenderer(Y.createDiffRenderer(Y.cloneDoc(doc), doc)) // baseline before formatting + // start maintaining the incremental cache (baseline == current, so no suggestions yet) + t.assert(root.delta.equals(delta.create().insert('a').done())) + root.format(0, 1, { bold: true }) // bold "a" + root.delete(0, 1) // delete "a" + const cached = root.delta + const fresh = root.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('delete-formatted cached :', JSON.stringify(cached.toJSON())) + console.error('delete-formatted toDelta:', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta drifted from toDelta({ deep: true }) after format+delete') +} + +/** + * Sanity: the maintained `delta` equals both an explicit expected delta and a fresh deep render — + * for flat content, nested children, and ongoing edits. + */ +export const testRdtDeltaSanity = () => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + root.insert(0, 'hello') + root.setAttr('k', 'v') + t.assert(root.delta.equals(delta.create().insert('hello').setAttr('k', 'v').done())) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + // nested child + ongoing edits keep delta == fresh deep render + const child = new Y.Node() + root.insert(5, [child]) + child.insert(0, 'world') + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + child.insert(5, '!') + root.delete(0, 1) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) + // the nested child's own cache is consistent too + t.assert(child.delta.equals(child.toDelta({ deep: true }))) + t.assert(child.delta.equals(delta.create().insert('world!').done())) +} + +/** + * Sanity: under a diffing-attribution renderer the maintained `delta` carries the expected + * attribution markers and equals a fresh attributed deep render. + */ +export const testRdtDeltaAttributionSanity = () => { + const ydoc = new Y.Doc() + const root = ydoc.get('root') + const v1 = Y.cloneDoc(ydoc) + const attributions = Y.createContentMap() + ydoc.on('update', (update, _origin, doc, tr) => { + if (!tr.local) return + const uid = doc.clientID.toString() + const cids = Y.createContentIdsFromUpdate(update) + Y.insertIntoIdMap(attributions.inserts, Y.createIdMapFromIdSet(cids.inserts, [Y.createContentAttribute('insert', uid)])) + Y.insertIntoIdMap(attributions.deletes, Y.createIdMapFromIdSet(cids.deletes, [Y.createContentAttribute('delete', uid)])) + }) + root.insert(0, 'hello') // a suggestion relative to v1 + const uid = ydoc.clientID.toString() + root.useRenderer(Y.createDiffRenderer(v1, ydoc, { attributions })) + t.assert(root.delta.equals(delta.create().insert('hello', null, { insert: [uid] }).done())) + t.assert(root.delta.equals(root.toDelta({ deep: true }))) +} + +/** + * Regression pin (originally a failing repro): formatting across a suggestion-deleted range with an + * accepting renderer (suggestionMode=false) drifts the maintained `.delta` + * cache — the change render attributes the formatted runs + * (`{format:{code:[]}}`) while a fresh deep render nets no format attribution + * (the format committed to base; it is not a suggestion). Also reproduces with + * two independent renderers on separate suggestion docs (fixed suggestionMode + * flags), i.e. without flipping the flag: a suggestion-mode peer deletes, a + * view-suggestions peer formats across the deleted range. + */ +export const testRdtFormatAcrossSuggestionDeletedDrift = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() + ) + const ynode = suggestionDoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) // materialize the maintained cache + // suggestion-delete "llo " (stays a suggestion; still rendered, attributed) + renderer.suggestionMode = true + ynode.applyDelta(delta.create().modify(delta.create().retain(2).delete(4)).done()) + // as an accepting user, format across the still-rendered deleted range + renderer.suggestionMode = false + ynode.applyDelta(delta.create().modify(delta.create().retain(1).retain(6, { code: {} })).done()) + const cached = ynode.delta + const fresh = ynode.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') +} + +/** + * Fixture for the delivery-through-deleted-parents tests: base doc + suggestion doc with pinned + * clientIDs (item/marker order at equal positions depends on clientID comparison — always pin, and + * exercise both orderings where it matters), a `paragraph('hello world')` created on the base doc + * (flows into the suggestion doc through the renderer), and optionally the diff renderer attached + * to the suggestion doc's root. + * + * @param {number} baseClientID + * @param {number} sdocClientID + * @param {boolean} useRootRenderer + */ +const createSuggestionPair = (baseClientID, sdocClientID, useRootRenderer = true) => { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() + ) + const ynode = sdoc.get('prosemirror') + if (useRootRenderer) ynode.useRenderer(renderer) + return { doc, sdoc, renderer, ynode } +} + +/** + * A remote base-doc insert into a suggestion-deleted paragraph must reach the root's RDT surface: + * the tombstone is still rendered by the diff renderer, so the change is visible. The event + * bubbles through the deleted parent (tracked unconditionally in `changedParentTypes`, fired on + * live ancestors), and the freshly inserted content — auto-deleted with its parent in the same + * transaction (`insertSet ∩ deleteSet`) yet attributed by the renderer — renders as a fresh + * insert carrying its delete attribution (`itemsToRender` includes `I∩D ∩ renderer.attributed`), + * so the maintained cache stays equal to a fresh deep render. + */ +export const testRdtDeltaThroughDeletedParent = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { doc, ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) // materialize the maintained cache + let fired = 0 + /** + * @type {any} + */ + let captured = null + ynode.on('delta', d => { fired++; captured = d }) + // suggestion-delete the whole paragraph (stays rendered as an attributed tombstone) + ynode.applyDelta(delta.create().delete(1).done()) + t.assert(fired === 1) + fired = 0 + // remote base edit inside the tombstone: integrates under the deleted paragraph in the + // suggestion doc (and is auto-deleted with it) — must still fire the root's 'delta' + doc.get('prosemirror').applyDelta(delta.create().modify(delta.create().retain(2).insert('XY')).done()) + t.assert(fired === 1, 'root delta fires for a change inside a suggestion-deleted paragraph') + t.assert(captured !== null && !captured.isEmpty()) + const fresh = ynode.toDelta({ deep: true }) + t.assert(JSON.stringify(fresh.toJSON()).includes('XY'), 'fresh render shows the text inside the tombstone') + const cached = ynode.delta + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A remote base-doc insert of a whole *nested type* into a suggestion-deleted paragraph: the + * fresh paragraph and all of its content are auto-deleted on integration yet attributed by the + * renderer, so the change renders it as a deep fresh insert (mode-3 through the ContentType + * branch) and the maintained cache stays equal to a fresh deep render. + */ +export const testRdtDeltaFreshTypeThroughDeletedParent = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { doc, ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) // materialize the maintained cache + let fired = 0 + ynode.on('delta', () => { fired++ }) + // suggestion-delete the whole paragraph (stays rendered as an attributed tombstone) + ynode.applyDelta(delta.create().delete(1).done()) + fired = 0 + // remote base edit: insert a fresh nested paragraph inside the tombstone + const freshParagraph = /** @type {any} */ (delta.create('paragraph', {}, 'fresh')) + const inner = /** @type {any} */ (delta.create().retain(2).insert([freshParagraph])) + doc.get('prosemirror').applyDelta(delta.create().modify(inner).done()) + t.assert(fired === 1, 'root delta fires for a fresh nested type inside a suggestion-deleted paragraph') + const fresh = ynode.toDelta({ deep: true }) + t.assert(JSON.stringify(fresh.toJSON()).includes('fresh'), 'fresh render shows the nested type inside the tombstone') + const cached = ynode.delta + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * Content inserted AND suggestion-deleted within the same transaction renders as nothing (a + * suggested insert that was taken back is invisible), and the maintained cache stays consistent — + * the `insertSet ∩ deleteSet` handling must not leak invisible content into the rendered state. + */ +export const testRdtDeltaSuggestedInsertThenDeleteInvisible = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { sdoc, ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) // materialize the maintained cache + // insert and delete the same content in ONE transaction on the suggestion doc + sdoc.transact(() => { + const par = /** @type {Y.Node} */ (ynode.get(0)) + par.applyDelta(delta.create().retain(2).insert('zz').done()) + par.applyDelta(delta.create().retain(2).delete(2).done()) + }) + const fresh = ynode.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('zz'), 'insert-then-deleted suggestion is invisible') + const cached = ynode.delta + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * Freshness (mode 3) must be decided per id range, not per item: a nested transaction (created + * from a 'delta' observer during another transaction's emit loop) has its freshly + * inserted+deleted item merged into an older left neighbor by the OUTER transaction's cleanup + * (`tryToMergeWithLefts`) before the nested transaction's events render. With a whole-item check + * (`insertedItems.hasId(item.id)` — first id only) the fresh range would render as a spurious + * `delete` op, removing content the cache legitimately holds. + */ +export const testRdtDeltaFreshRangeAfterItemMerge = () => { + const doc = new Y.Doc({ gc: false }) + doc.clientID = 1 + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = 2 + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('t').applyDelta(delta.create().insert('XY').done()) + const ynode = sdoc.get('t') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) // materialize the maintained cache + let reacted = false + ynode.on('delta', change => { + if (reacted || !JSON.stringify(change.toJSON()).includes('"a"')) return + reacted = true + // nested transaction, cleaned up after the outer one: insert 'b' right after the suggested + // 'a' (adjacent clock, same client), then delete both — the outer cleanup merges the two + // deleted items into one before this transaction's events render + sdoc.transact(() => { + ynode.applyDelta(delta.create().retain(1).insert('b').done()) + ynode.applyDelta(delta.create().delete(2).done()) + }) + }) + // outer transaction: suggested insert 'a' at position 0 + ynode.applyDelta(delta.create().insert('a').done()) + t.assert(reacted) + const cached = ynode.delta + const fresh = ynode.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') +} + +/** + * The lib0 `RDT` fix contract for a *fully reverted* apply: nothing landed on the doc, the + * maintained cache stays consistent, and `before.apply(d).apply(fix)` round-trips back to the + * actual (unchanged) rendered state — the fix is the inverse of the unapplied change. + * + * @param {any} ynode the type whose maintained `.delta` cache to check (must be materialized) + * @param {any} before deep render of `ynode` captured before the apply + * @param {any} d the change that was (not) applied + * @param {any} fix the fix `applyDelta` returned + */ +const assertRevertedApply = (ynode, before, d, fix) => { + const fresh = ynode.toDelta({ deep: true }) + t.assert(delta.diff(before, fresh).isEmpty(), 'nothing was applied to the doc') + t.assert(ynode.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + const roundTrip = delta.cloneDeep(before) + roundTrip.apply(delta.cloneDeep(d), { final: true, move: true }) + if (fix !== null) { + roundTrip.apply(delta.cloneDeep(fix), { final: true, move: true }) + } + t.assert(delta.diff(roundTrip, fresh).isEmpty(), 'the fix round-trips the expected state back to the actual state') +} + +/** + * Modifying a suggestion-deleted (rendered) node must not apply anything — `applyDelta` returns + * the reverted operation (the inverse of the nested change) as the RDT fix, emits no 'delta' + * event (nothing changed), and leaves doc + cache untouched. + */ +export const testRdtApplyDeltaModifyIntoTombstoneReturnsInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) // materialize the maintained cache + ynode.applyDelta(delta.create().delete(1).done()) + const before = ynode.toDelta({ deep: true }) + let fired = 0 + ynode.on('delta', () => { fired++ }) + const d = delta.create().modify(delta.create().retain(2).insert('XY')).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null, 'the reverted operation is returned') + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(delta.create().retain(2).delete(2)).done().toJSON()) + t.assert(fired === 0, 'a fully reverted apply emits no delta event') + t.assert(!JSON.stringify(ynode.toDelta({ deep: true }).toJSON()).includes('XY'), 'the insert was not applied') + assertRevertedApply(ynode, before, d, fix) + } +} + +/** + * Deleting content inside a tombstone: the fix re-inserts the deleted range from the rendered + * base state, restoring its stored attribution (the caller's view shows the content + * delete-attributed — the revert must bring exactly that back). + */ +export const testRdtApplyDeltaDeleteInsideTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) + ynode.applyDelta(delta.create().delete(1).done()) + const before = ynode.toDelta({ deep: true }) + const d = delta.create().modify(delta.create().retain(2).delete(4)).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null) + const fixJson = JSON.stringify(/** @type {any} */ (fix).toJSON()) + t.assert(fixJson.includes('llo '), 'the fix re-inserts the deleted range') + t.assert(fixJson.includes('"delete"'), 'the re-insert restores the stored delete attribution') + assertRevertedApply(ynode, before, d, fix) + } +} + +/** + * Formatting content inside a tombstone: not applied (no markers created); the fix clears the + * format keys back to the base values (`{ bold: null }` for a previously unformatted range). + */ +export const testRdtApplyDeltaFormatInsideTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) + ynode.applyDelta(delta.create().delete(1).done()) + const before = ynode.toDelta({ deep: true }) + const d = delta.create().modify(delta.create().retain(1).retain(4, { bold: {} })).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(delta.create().retain(1).retain(4, { bold: null })).done().toJSON()) + assertRevertedApply(ynode, before, d, fix) + } +} + +/** + * A `modify` carrying node formats on a tombstone: `op.format` is not applied either, and the fix + * restores the *previous* format value — read from the cursor's format context AFTER stepping to + * the node, so an alive format marker between the walk's start and the node is accounted for. + */ +export const testRdtApplyDeltaNodeFormatOnTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'bb')]).done() + ) + // base-doc node format over both paragraphs: an alive `align` marker sits before the first one + doc.get('prosemirror').applyDelta(delta.create().retain(2, { align: 'x' }).done()) + const ynode = sdoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) + // suggestion-delete the second paragraph + ynode.applyDelta(delta.create().retain(1).delete(1).done()) + const before = ynode.toDelta({ deep: true }) + const d = delta.create().retain(1).modify(delta.create(), { align: 'y' }).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(1).modify(delta.create(), { align: 'x' }).done().toJSON()) + assertRevertedApply(ynode, before, d, fix) + } +} + +/** + * Applied and reverted ops coexist in one delta: ops on live content land (one 'delta' event), + * only the tombstone-targeting modify reverts — and the fix's retain pad is measured in the + * caller's *expected* space (including `d`'s own earlier insert). + */ +export const testRdtApplyDeltaMixedFixCoordinates = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([ + delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') + ]).done() + ) + const ynode = sdoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) + // suggestion-delete the middle paragraph + ynode.applyDelta(delta.create().retain(1).delete(1).done()) + let fired = 0 + ynode.on('delta', () => { fired++ }) + const innerXY = /** @type {any} */ (delta.create().retain(2).insert('XY')) + const innerZZ = /** @type {any} */ (delta.create().retain(2).insert('ZZ')) + const pNew = /** @type {any} */ (delta.create('paragraph', {}, 'nn')) + const d = /** @type {any} */ (delta.create()).insert([pNew]).retain(1).modify(innerXY).modify(innerZZ).done() + const fix = ynode.applyDelta(d) + t.assert(fired === 1, 'the applied part of the change emits exactly one delta event') + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(2).modify(delta.create().retain(2).delete(2)).done().toJSON()) + const fresh = JSON.stringify(ynode.toDelta({ deep: true }).toJSON()) + t.assert(fresh.includes('nn') && fresh.includes('ZZ'), 'ops on live content were applied') + t.assert(!fresh.includes('XY'), 'the tombstone-targeting modify was not applied') + t.assert(ynode.delta.equals(ynode.toDelta({ deep: true })), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * `modifyAttr` addressing a suggestion-deleted (still rendered) map value: the renderer-aware + * lookup finds the tombstone, nothing is applied, and the fix wraps the inverse in a + * `modifyAttr` — previously this hit `unexpectedCase` (`nodeMapGet` is blind to deleted items). + */ +export const testRdtApplyDeltaModifyAttrOnDeletedMapValue = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + const title = doc.get('m').setAttr('title', new Y.Node()) + title.insert(0, 'hi') + const m = sdoc.get('m') + m.useRenderer(renderer) + t.assert(m.delta != null) + // suggestion-delete the attribute (stays rendered, delete-attributed) + m.applyDelta(delta.create().deleteAttr('title').done()) + const before = m.toDelta({ deep: true }) + const d = delta.create().modifyAttr('title', delta.create().insert('X')).done() + const fix = m.applyDelta(d) + t.assert(fix !== null, 'no throw — the reverted operation is returned') + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modifyAttr('title', delta.create().delete(1)).done().toJSON()) + assertRevertedApply(m, before, d, fix) + } +} + +/** + * A plainly deleted (invisible — no renderer claims it) type keeps today's semantics: the apply + * is silently dropped and `applyDelta` returns `null` (the caller's view shows nothing there, so + * there is nothing to revert). + */ +export const testRdtApplyDeltaInvisibleDeletedSilentNull = () => { + const doc = new Y.Doc({ gc: false }) + doc.clientID = 1 + const root = doc.get('prosemirror') + root.applyDelta(delta.create().insert([delta.create('paragraph', {}, 'hello')]).done()) + const par = /** @type {Y.Node} */ (root.get(0)) + root.applyDelta(delta.create().delete(1).done()) + const res = par.applyDelta(delta.create().retain(2).insert('XY').done()) + t.assert(res === null, 'invisible deleted type: silent drop, no fix') + t.assert(root.toDelta({ deep: true }).isEmpty(), 'nothing was applied') +} + +/** + * `applyDelta` called directly on a deleted-but-rendered type (the top-level guard): nothing is + * applied and the inverse against the rendered state is returned. Without a renderer the same + * call stays a silent `null` drop. + */ +export const testRdtApplyDeltaDirectGuardOnDeletedType = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ynode, renderer } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) + const par = /** @type {Y.Node} */ (ynode.get(0)) + ynode.applyDelta(delta.create().delete(1).done()) + const rootBefore = ynode.toDelta({ deep: true }) + const d = /** @type {any} */ (delta.create().retain(2).insert('XY').done()) + // children do not inherit the root's renderer — without one the node is invisible + t.assert(par.applyDelta(d) === null, 'no renderer: silent drop') + const fix = par.applyDelta(d, null, { renderer }) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(2).delete(2).done().toJSON()) + t.assert(delta.diff(rootBefore, ynode.toDelta({ deep: true })).isEmpty(), 'nothing was applied to the doc') + t.assert(ynode.delta.equals(ynode.toDelta({ deep: true })), 'maintained .delta must equal a fresh deep render') + // fix round-trip at the node level, against its rendered state + const parBefore = /** @type {any} */ (par.toDelta({ deep: true, renderer })) + const roundTrip = /** @type {any} */ (delta.cloneDeep(parBefore)) + roundTrip.apply(delta.cloneDeep(d), { final: true, move: true }) + roundTrip.apply(delta.cloneDeep(/** @type {any} */ (fix)), { final: true, move: true }) + t.assert(delta.diff(roundTrip, par.toDelta({ deep: true, renderer })).isEmpty(), 'the fix round-trips at the node level') + } +} + +/** + * A tombstone grandchild behind an alive child: the alive child's applyDelta bubbles the nested + * fix, and the parent wraps it positionally — `modify(modify(inverse))`. + */ +export const testRdtApplyDeltaNestedTombstoneFixBubbles = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, [delta.create('nested', {}, 'ww')])]).done() + ) + const ynode = sdoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) + // suggestion-delete only the nested node inside the (alive) paragraph + const par = /** @type {Y.Node} */ (ynode.get(0)) + par.applyDelta(delta.create().delete(1).done(), null, { renderer }) + const before = ynode.toDelta({ deep: true }) + const innerX = /** @type {any} */ (delta.create().insert('X')) + const midModify = /** @type {any} */ (delta.create().modify(innerX)) + const d = delta.create().modify(midModify).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null) + const innerDel = /** @type {any} */ (delta.create().delete(1)) + const midModifyDel = /** @type {any} */ (delta.create().modify(innerDel)) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(midModifyDel).done().toJSON()) + assertRevertedApply(ynode, before, d, fix) + } +} + +/** + * A plain `delete` op spanning a tombstone keeps its existing semantics (suggestion-deletes the + * alive content, records the attributed range) and returns no fix — the known, downstream-healed + * gap; pinned here so a change of behavior is a conscious one. + */ +export const testRdtApplyDeltaPureDeleteOverTombstoneNoFix = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([ + delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') + ]).done() + ) + const ynode = sdoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) + ynode.applyDelta(delta.create().retain(1).delete(1).done()) + const res = ynode.applyDelta(delta.create().delete(3).done()) + t.assert(res === null, 'a plain delete over a tombstone range returns no fix') + const fresh = ynode.toDelta({ deep: true }) + t.assert(ynode.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A `delete` op ending mid-way through a struck (attributed-deleted) chunk must split the item at + * the consumption boundary — otherwise the cursor advances past the whole chunk and every + * following op of the same delta targets too far right (the modify walk then reverts the WRONG + * node, returning a fix that carries another node's content). + */ +export const testRdtApplyDeltaDeleteMidStruckChunkKeepsCursorSync = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + doc.get('t').applyDelta(delta.create().insert('abc').done()) + doc.get('t').applyDelta(delta.create().retain(3).insert([delta.create('nA', {}, 'kk'), delta.create('nB', {}, 'qqq')]).done()) + const ynode = sdoc.get('t') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) + // strike 'bc' (one 2-unit chunk) and both nodes + ynode.applyDelta(delta.create().retain(1).delete(2).done()) + ynode.applyDelta(delta.create().retain(3).delete(2).done()) + const before = ynode.toDelta({ deep: true }) + // delete struck 'b' (ends MID-chunk), retain struck 'c', revert-modify tombstone nA + const inner = /** @type {any} */ (delta.create().delete(2)) + const d = delta.create().retain(1).delete(1).retain(1).modify(inner).done() + const fix = ynode.applyDelta(d) + t.assert(fix !== null) + const fixJson = JSON.stringify(/** @type {any} */ (fix).toJSON()) + t.assert(fixJson.includes('kk') && !fixJson.includes('qq'), "the fix restores nA's content, not nB's") + const fresh = ynode.toDelta({ deep: true }) + t.assert(delta.diff(before, fresh).isEmpty(), 'nothing was applied (struck delete is meta-only, modify reverted)') + t.assert(ynode.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A change *inside* a suggestion-deleted (still rendered) attr value must re-emit: the deleted + * value has no modifyAttr path, so the change render re-emits the full-state `setAttr` (an + * idempotent replace) whenever the value type is in `modified` — else the maintained cache and + * every RDT consumer go permanently stale. + */ +export const testRdtDeltaThroughDeletedAttrValue = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attributions: Y.createContentMap() }) + const title = doc.get('m').setAttr('title', new Y.Node()) + title.insert(0, 'hi') + const m = sdoc.get('m') + m.useRenderer(renderer) + t.assert(m.delta != null) + m.applyDelta(delta.create().deleteAttr('title').done()) + t.assert(m.delta.equals(m.toDelta({ deep: true })), 'cache consistent after the suggestion deleteAttr') + let fired = 0 + m.on('delta', () => { fired++ }) + // base-doc edit INSIDE the tombstone attr value + doc.get('m').getAttr('title').insert(2, 'XY') + t.assert(fired === 1, "'delta' fires for a change inside the tombstone attr value") + const fresh = m.toDelta({ deep: true }) + t.assert(JSON.stringify(fresh.toJSON()).includes('XY'), 'fresh render shows the edit') + t.assert(m.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A remote base-doc format inside a suggestion-deleted paragraph: full contract — the root's + * 'delta' fires and the maintained cache equals a fresh deep render (fresh-deleted format markers + * stay on the retained-marker path of the format state machine). + */ +export const testRdtDeltaFormatThroughDeletedParent = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { doc, ynode } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ynode.delta != null) // materialize the maintained cache + ynode.applyDelta(delta.create().delete(1).done()) // suggestion-delete the paragraph + let fired = 0 + ynode.on('delta', () => { fired++ }) + doc.get('prosemirror').applyDelta(delta.create().modify(delta.create().retain(1).retain(4, { bold: {} })).done()) + t.assert(fired === 1, 'root delta fires for a format inside a suggestion-deleted paragraph') + const cached = ynode.delta + const fresh = ynode.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * Plain docs (base renderer): deleted content is invisible, so a remote change inside a deleted + * paragraph emits no 'delta' (the change renders to an empty delta, which is suppressed) and v1 + * `observe` semantics are unchanged. Deep listeners on live ancestors ARE notified now (the event + * is tracked through the deleted parent) — that is the chosen semantics. + */ +export const testRdtNoDeltaThroughDeletedParentPlainDoc = () => { + const docP = new Y.Doc({ gc: false }) + docP.clientID = 3 + const docQ = new Y.Doc({ gc: false }) + docQ.clientID = 4 + docP.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() + ) + Y.applyUpdate(docQ, Y.encodeStateAsUpdate(docP)) + const rootQ = docQ.get('prosemirror') + t.assert(rootQ.delta != null) // materialize the maintained cache + let deltaFired = 0 + let deepFired = 0 + let observeFired = 0 + rootQ.on('delta', () => { deltaFired++ }) + rootQ.observeDeep(() => { deepFired++ }) + rootQ.observe(() => { observeFired++ }) + // plain-delete the paragraph on Q — a visible change, fires normally + rootQ.applyDelta(delta.create().delete(1).done()) + t.assert(deltaFired === 1 && observeFired === 1) + deltaFired = 0 + deepFired = 0 + observeFired = 0 + // remote edit inside the (invisible) deleted paragraph + docP.get('prosemirror').applyDelta(delta.create().modify(delta.create().retain(2).insert('XY')).done()) + Y.applyUpdate(docQ, Y.encodeStateAsUpdate(docP)) + t.assert(deltaFired === 0, 'no delta emission for an invisible change') + t.assert(observeFired === 0, 'v1 observe on the root is unaffected') + t.assert(deepFired === 1, 'deep listeners on live ancestors are notified') + t.assert(rootQ.delta.equals(rootQ.toDelta({ deep: true })), 'maintained cache stays equal to a fresh render') +} + +/** + * A deleted type with its OWN custom renderer keeps firing (its content is still rendered): the + * deletion transaction and later remote edits inside the tombstone reach its `observe`/'delta' + * and keep its maintained cache current — while a base-renderer root above it stays silent for + * changes it cannot see. + */ +export const testRdtDeletedTypeWithOwnRendererFires = () => { + const { doc, renderer, ynode } = createSuggestionPair(1, 2, false) + const parB = /** @type {any} */ (ynode.get(0)) + parB.useRenderer(renderer) + t.assert(parB.delta != null) // materialize the maintained cache + t.assert(ynode.delta != null) // root cache, maintained under the base renderer + let parFired = 0 + let parObserved = 0 + let rootFired = 0 + parB.on('delta', () => { parFired++ }) + parB.observe(() => { parObserved++ }) + ynode.on('delta', () => { rootFired++ }) + // delete the paragraph (a visible change on the root; the paragraph itself becomes a tombstone + // that its own diff renderer still renders) + ynode.applyDelta(delta.create().delete(1).done()) + t.assert(rootFired === 1, 'root fires for its own visible delete') + t.assert(parB.delta.equals(parB.toDelta({ deep: true })), 'deleted type cache current after the deletion') + const parFiredAfterDelete = parFired + // remote format inside the tombstone: the deleted type fires; the base-renderer root renders + // nothing and must not emit an empty delta + doc.get('prosemirror').applyDelta(delta.create().modify(delta.create().retain(1).retain(4, { bold: {} })).done()) + t.assert(parFired === parFiredAfterDelete + 1, 'deleted type with its own renderer fires delta') + t.assert(parObserved >= 1, 'deleted type with its own renderer fires observe') + t.assert(rootFired === 1, 'base-renderer root does not emit empty deltas') + t.assert(parB.delta.equals(parB.toDelta({ deep: true })), 'deleted type cache current after the remote format') + t.assert(ynode.delta.equals(ynode.toDelta({ deep: true })), 'root cache stays equal to a fresh render') +} + +/** + * Type-scoped UndoManager: tombstone-subtree transactions now reach `changedParentTypes` (the + * scope check), but origin gating still decides capture — an untracked-origin remote change is + * not captured. + */ +export const testRdtDeletedSubtreeUndoScope = () => { + const docP = new Y.Doc({ gc: false }) + docP.clientID = 5 + const docQ = new Y.Doc({ gc: false }) + docQ.clientID = 6 + docP.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'hello world')]).done() + ) + Y.applyUpdate(docQ, Y.encodeStateAsUpdate(docP)) + const rootQ = docQ.get('prosemirror') + const um = new Y.UndoManager(rootQ) + rootQ.applyDelta(delta.create().delete(1).done()) // local delete → captured + t.assert(um.undoStack.length === 1) + // remote change inside the deleted paragraph with an untracked origin: in scope via the + // deleted-parent bubble, but not captured + docP.get('prosemirror').applyDelta(delta.create().modify(delta.create().retain(2).insert('XY')).done()) + Y.applyUpdate(docQ, Y.encodeStateAsUpdate(docP), 'remote-origin') + t.assert(um.undoStack.length === 1, 'untracked-origin remote change is not captured') +} + +/** + * Regression pin (originally a failing repro): an *accepting* (suggestionMode=false) child-node insert with no + * suggested content anywhere drifts the maintained `.delta` cache. The inserted node reaches the + * base doc (asserted below — it is committed content, NOT a suggestion, so the + * "inserted-adjacent-to-suggested-is-suggested" rule does not apply), and a fresh deep render + * correctly shows no attribution — but the cache keeps the change render's transient + * `{insert: []}` attribution. Suggestion-mode inserts and accepting inserts adjacent to suggested + * content are consistent (both sides attributed); only this committed-insert case drifts. + * Delete-tail + insert-node is the CRDT shape of a ProseMirror block split, so editor workloads + * hit this constantly. + */ +export const testRdtAcceptingNodeInsertCacheDrift = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) + renderer.suggestionMode = false + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() + ) + const ynode = suggestionDoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) // materialize the maintained cache + ynode.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'plain')]).done()) + // the insert is committed content: it reached the base doc + t.assert(JSON.stringify(doc.get('prosemirror').toDeltaDeep().toJSON()).includes('plain'), 'the insert committed to base') + const cached = ynode.delta + const fresh = ynode.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('"attribution"'), 'fresh render shows committed (unattributed) content') + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + // same class, second entry point: ACCEPTING a suggested node-insert. The suggested insert + // itself is consistent (both sides attributed), but the accept's de-attribution correction + // does not descend into the nested node's content — the cache keeps `{insert: []}`. + { + const doc2 = new Y.Doc({ gc: false }) + const suggestionDoc2 = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer2 = Y.createDiffRenderer(doc2, suggestionDoc2, { attributions: Y.createContentMap() }) + doc2.get('prosemirror').applyDelta(delta.create().insert([delta.create('paragraph', {}, 'base para')]).done()) + const ynode2 = suggestionDoc2.get('prosemirror') + ynode2.useRenderer(renderer2) + t.assert(ynode2.delta != null) // materialize the maintained cache + renderer2.suggestionMode = true + ynode2.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'sugg')]).done()) + t.assert(ynode2.delta.equals(ynode2.toDelta({ deep: true })), 'suggested insert itself is consistent') + renderer2.acceptAllChanges() + t.assert(ynode2.delta.equals(ynode2.toDelta({ deep: true })), 'maintained .delta must equal a fresh render after accepting the node insert') + } +} + +/** + * Regression pin (originally a failing repro) — the consumer-visible framing of the cache drift above: inserting a + * node through an *accepting* renderer (`suggestionMode = false`) must not leave it presented as + * a suggestion. The write currently emits two `'delta'` events: first the insert render, fully + * attributed (`{insert: []}` on the inserted node AND its nested content), then a de-attribution + * correction `retain(1).retain(1, {attribution: null})` once the content commits to base — but + * the correction only clears the attribution on the node itself and never descends into the + * node's children. Composing the event stream (which is exactly how the maintained `.delta` + * cache is built) therefore leaves the nested text attributed as a suggested insert forever, + * while ground truth (a fresh deep render) shows committed, unattributed content. The test does + * not prescribe the fix: it passes if the insert render arrives unattributed OR if the + * correction descends — it only requires the settled event stream to converge to the truth. + */ +export const testRdtAcceptingNodeInsertRenderedAsSuggestion = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) + renderer.suggestionMode = false + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() + ) + const ynode = suggestionDoc.get('prosemirror') + ynode.useRenderer(renderer) + // composed = pre-write state + every emitted change: what any consumer of the `'delta'` + // channel (a remote binding, the maintained cache) believes the document looks like + const composed = delta.cloneDeep(/** @type {any} */ (ynode.toDelta({ deep: true }))) + ynode.on('delta', d => { + composed.apply(/** @type {any} */ (delta.cloneDeep(/** @type {any} */ (d))), { final: true, move: true }) + }) + ynode.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'plain')]).done()) + const fresh = ynode.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('"attribution"'), 'ground truth: the insert committed to base, nothing is suggested') + if (!composed.equals(fresh)) { + console.error('composed:', JSON.stringify(composed.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(!JSON.stringify(composed.toJSON()).includes('"attribution"'), 'the settled event stream must not present the committed insert as a suggestion') + t.assert(composed.equals(fresh), 'composing the emitted changes converges to a fresh render') + // deeper nesting must heal at *every* level below the top, not just the first + ynode.applyDelta(delta.create().retain(2).insert([delta.create('blockquote', {}, [delta.create('paragraph', {}, 'deep')])]).done()) + const fresh2 = ynode.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh2.toJSON()).includes('"attribution"'), 'ground truth: the multi-level insert committed to base') + t.assert(!JSON.stringify(composed.toJSON()).includes('"attribution"'), 'no nesting level is left presented as a suggestion') + t.assert(composed.equals(fresh2), 'the event stream converges for multi-level nesting') +} + +/** + * Guards the id-scoped heal design: unattributing an accepted node must never cascade into a + * blanket subtree clear, because children may still be attributed. Here only the node's OWN + * insert struct is committed to base — `acceptChanges` ships an accepted node together with its + * subtree (see {@link testRdtRangeAcceptShipsNestedStructs}), so the partial state is built by + * hand through the raw struct path the accept uses — the node commits to base as an empty + * paragraph while its text children remain pending suggestions. The heal must clear the node's + * attribution, keep the children's `{insert: []}`, and keep the maintained cache equal to a fresh + * render. + */ +export const testRdtPartialAcceptKeepsPendingChildSuggestions = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) + doc.get('prosemirror').applyDelta(delta.create().insert([delta.create('paragraph', {}, 'base para')]).done()) + const ynode = suggestionDoc.get('prosemirror') + ynode.useRenderer(renderer) + t.assert(ynode.delta != null) // materialize the maintained cache + renderer.suggestionMode = true + const client = suggestionDoc.clientID + // suggestion 1: node insert — the paragraph node takes clock 0, its text 'x' clock 1 + ynode.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'x')]).done()) + // suggestion 2: more text inside the suggested node + ynode.applyDelta(delta.create().retain(1).modify(delta.create().retain(1).insert('Q')).done()) + t.assert(ynode.delta.equals(ynode.toDelta({ deep: true })), 'consistent before the partial accept') + // commit ONLY the node's own struct (clock 0); every child stays a pending suggestion + const encoder = new Y.UpdateEncoderV1() + const nodeOnly = Y.createIdSet() + nodeOnly.add(client, 0, 1) + writeStructsFromIdSet(encoder, suggestionDoc.store, nodeOnly) + Y.writeIdSet(encoder, Y.createIdSet()) + Y.applyUpdate(doc, encoder.toUint8Array()) + const cached = ynode.delta + const fresh = ynode.toDelta({ deep: true }) + const freshJson = /** @type {any} */ (fresh.toJSON()) + // ground truth: the node committed (as an empty paragraph) … + t.assert(JSON.stringify(doc.get('prosemirror').toDeltaDeep().toJSON()).split('"paragraph"').length === 3, 'the accepted node reached the base doc') + t.assert(freshJson.children[0].attribution === undefined, 'the accepted node itself is no longer attributed') + // … while its children are still suggested + const acceptedPara = freshJson.children[0].insert[1] + t.assert(acceptedPara.name === 'paragraph' && JSON.stringify(acceptedPara.children).includes('"attribution":{"insert":[]}'), 'pending child suggestions keep their attribution') + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after the partial accept') +} + +/** + * A range accept ships an accepted node together with its subtree. `collectSuggestedChanges` used + * to collect only the top-level item chain of the accepted range, so accepting a pending node + * insert committed the bare node struct: an image reached the base doc without its `src`, a + * paragraph without its text, a blockquote without its paragraphs (found through y-prosemirror's + * cohort fuzz — its known issue 7 — where a base-bound editor then threw on the image without its + * required attr). A nested item can only exist in the suggestion doc if its parent does, so the + * subtree is part of the same suggestion. + */ +export const testRdtRangeAcceptShipsNestedStructs = () => { + /** + * @param {string} label + * @param {(snode: any) => void} suggest one suggested node insert whose node struct takes clock 0 + * @param {(baseJson: string) => boolean} committed what the base doc's deep render must hold + */ + const check = (label, suggest, committed) => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bnode = base.get('prosemirror') + const snode = sugg.get('prosemirror') + bnode.applyDelta(delta.create().insert([delta.create('paragraph', {}, 'ab')]).done()) + snode.useRenderer(renderer) + t.assert(snode.delta != null) // materialize the maintained cache + suggest(snode) + t.assert(JSON.stringify(snode.toDelta({ deep: true }).toJSON()).includes('"attribution":{"insert":[]}'), `${label}: suggested`) + renderer.acceptChanges(Y.createID(1, 0), Y.createID(1, 0)) + const baseJson = JSON.stringify(bnode.toDeltaDeep().toJSON()) + t.assert(committed(baseJson), `${label}: the accepted node reached the base doc with its subtree (${baseJson})`) + const fresh = snode.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('"attribution"'), `${label}: nothing is left presented as a suggestion`) + t.assert(snode.delta.equals(fresh), `${label}: maintained .delta equals a fresh deep render after the accept`) + } + check( + 'image with a required attr', + snode => { + // untyped builders keep tsc's inference shallow here + const paragraph = /** @type {any} */ (delta.create()).retain(1).insert([delta.create('image', { src: 'x.png' })]) + snode.applyDelta(/** @type {any} */ (delta.create()).modify(paragraph).done()) + }, + json => json.includes('"name":"image"') && json.includes('"src":{"type":"insert","value":"x.png"}') + ) + check( + 'paragraph with text', + snode => snode.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'text')]).done()), + json => json.split('"paragraph"').length === 3 && json.includes('"insert":"text"') + ) + check( + 'blockquote with a nested paragraph', + snode => snode.applyDelta(delta.create().retain(1).insert([delta.create('blockquote', {}, [delta.create('paragraph', {}, 'deep')])]).done()), + json => json.includes('"name":"blockquote"') && json.includes('"insert":"deep"') + ) +} + +/** + * Regression pin — fixed by `isRangeEndClear` in the ynode format walk (a rendered unattributed + * marker closing an attributed same-key range must emit the per-key attribution clear). + * Minimized from y-prosemirror's `.dbg-fuzz.mjs` seed 54321, op #8; 14/25 fuzz seeds hit this + * class: a *base-doc* format arrives over an overlapping same-key *suggested* format, then a + * plain base-doc insert lands inside the formatted range (past the suggested span, with + * formatted content still following) — the change render emitted for the insert failed to close + * the attributed-format range, so the maintained `.delta` cache kept a stale + * `{format: {strong: []}}` on the trailing content while a fresh render showed it committed: + * + * cached: a(strong) | bx({format:{strong:[]}}) | c(strong, {format:{strong:[]}} ← STALE) + * fresh : a(strong) | bx({format:{strong:[]}}) | c(strong) + * + * The drift appeared alongside the `usedFormats`/`useFormats()` rework (yjs 804e4d34 + + * lib0 7c1d44e) — it reproduces with the previous lib0 as well, so the yjs-side walk is + * sufficient to trigger it. It needs the insert strictly *inside* the base-format range and + * *after* the suggested span; inserting at the suggested span's boundary or at the range end + * renders correctly. + */ +export const testRdtBaseInsertIntoOverlappingFormatRangeCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('abc').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // suggested strong on 'a' + st.applyDelta(delta.create().retain(1, { strong: {} }).done()) + // base strong over all of 'abc' — a base-doc format arriving under an overlapping same-key + // format suggestion + bt.applyDelta(delta.create().retain(3, { strong: {} }).done()) + // plain base insert between 'b' and 'c' → the emitted change must end the attributed range, + // but leaves the trailing 'c' attributed in the cache + bt.applyDelta(delta.create().retain(2).insert('x').done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a base insert into the overlapping format range') +} + +/** + * Regression pin — fixed by installing the all-null-leaves context copy in the attribution + * install gate. Sibling of the class above, found by grid-enumerating the repro skeleton + * (192 same-class combos in the suggested-insert pass, 496 more with formatted base inserts): + * with TWO overlapping suggested format keys, the change-render copy of the ambient attributed + * format context carries per-key null-clear leaves (which fresh contexts never hold), so the + * emptiness test at the install gate failed where the fresh walk reaches its empty-copy + * `useAttribution(null)` closure — an unattributed close marker's key deletion was discarded. + * The stale `{format: {strong: []}}` then surfaced in the cache on the next change render that + * walks the span: a suggested insert *inside* the em range (an insert past it renders clean): + * + * cached: a(em+strong, both attributed) | x({insert:[]}) | b(…) | cd({format:{strong:[]}} ← STALE) + * fresh : a(em+strong, both attributed) | x({insert:[]}) | b(…) | cd + * + * The marker integration order is load-bearing: it reproduces with base.clientID > sugg.clientID + * and renders clean with the order flipped. + */ +export const testRdtSuggestedInsertUnderTwoKeySuggestedFormatsCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 1 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 0 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('abcd').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // two overlapping suggested formats → a two-key attributed format context over 'ab' + st.applyDelta(delta.create().retain(2, { em: {} }).done()) + st.applyDelta(delta.create().retain(4, { strong: {} }).done()) + // base strong over 'ab' — same key/value as the suggestion; the cache is still clean here + bt.applyDelta(delta.create().retain(2, { strong: {} }).done()) + // suggested insert inside the em range → the change render re-stamps the trailing spans and + // flushes the stale strong governance into the cache + st.applyDelta(delta.create().retain(1).insert('x').done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a suggested insert under a two-key suggested format context') +} + +/** + * Regression pin — fixed by the same install-gate change as the two-key class above (the + * all-null-leaves context copy must be installed). Formatted-base-insert sibling (grid class B, + * 496 combos in the formatted-base-insert fuzzing pass): under a two-key + * suggested format context (em over 'ab', strong over 'a'), a base-doc em arrives on 'a' + * only — strictly undercovering the suggested em range — so the em governance over the + * trailing 'b' stays attributed-stale in the cache. A base insert *formatted with the other + * suggested key* ({strong:{}}) before the context then flushes the stale em into the cache: + * + * cached: x(strong+em, attributed) | a(em, attributed) | b({format:{em:[]}} ← STALE) + * fresh : x(strong+em, attributed) | a(em, attributed) | b + * + * Every knob is load-bearing: a plain base insert, an em- or third-key-formatted insert, + * inserting at position 1 or 2, base em covering the full suggested em range, suggested + * strong covering the full em range, or dropping any of the three format ops all render + * clean — so this does not collapse into the plain-insert siblings above. Like the sibling, + * it needs base.clientID > sugg.clientID and renders clean with the order flipped. + */ +export const testRdtFormattedBaseInsertUnderTwoKeySuggestedFormatsCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 1 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 0 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('ab').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // two overlapping suggested formats → a two-key attributed format context, em past strong + st.applyDelta(delta.create().retain(2, { em: {} }).done()) + st.applyDelta(delta.create().retain(1, { strong: {} }).done()) + // base em on 'a' only — same key/value as the suggestion but undercovering its range, so + // the em governance over the trailing 'b' stays stale; the cache is still clean here + bt.applyDelta(delta.create().retain(1, { em: {} }).done()) + // base insert formatted with the OTHER suggested key, before the context → the change + // render re-stamps the trailing spans and flushes the stale em governance into the cache + bt.applyDelta(delta.create().insert('x', { strong: {} }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a formatted base insert under a two-key suggested format context') +} + +/** + * Regression pin — fixed by the `previousFormats` cache-reference rework in `# Update Formats` + * (a marker deleted by the change whose value equals the walk's current value dropped out of + * the tracking, so a later marker was misread as a restore-to-previous and the needed format + * diff was swallowed). FORMAT-VALUE divergence, minimized from fuzz-core seeds 711 and 4935, + * which both collapse to this one 4-op skeleton — the two seeds are mirror directions of it, + * `em:{}` leaking format IN vs `em:null` leaking format OUT: + * when a base-doc format op's range END lands strictly *inside* a same-key suggested-format + * span, the change render stamped the base op's format one char PAST its range end. The cache + * then disagrees with a fresh deep render about the EFFECTIVE FORMAT of that boundary char — + * not merely its attribution (no attribution differs; none is even present): + * + * cached: abxx(em) ← trailing 'x' wrongly formatted + * fresh : abx(em) | x(no format) + * + * Every ingredient is load-bearing: the pre-existing em on 'b', the TWO appended chars (with a + * single 'x' there is no boundary char inside the suggested span), the suggestion straddling + * the base range end (covering only one of the two 'x's renders clean), the base op ending + * strictly inside the span (retain(4) renders clean), same key on both sides (a `strong` + * suggestion renders clean), and the order suggestion-before-base-format (flipped renders + * clean). Reproduces identically with both clientID orders (0/1 and 1/0). + */ +export const testRdtBaseFormatEndingInsideSuggestedFormatRangeFormatValueCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('ab').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // pre-existing base em on 'b' + bt.applyDelta(delta.create().retain(1).retain(1, { em: {} }).done()) + // plain base append after the em run + bt.applyDelta(delta.create().retain(2).insert('xx').done()) + // suggested em over the appended 'xx' — same key as the base format below + st.applyDelta(delta.create().retain(2).retain(2, { em: {} }).done()) + // base em over 'abx' — the range end lands strictly inside the suggested span; the cache + // bleeds the em onto the trailing 'x' as its *format* (fresh leaves it unformatted) + bt.applyDelta(delta.create().retain(3, { em: {} }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a base format ending inside a same-key suggested format range') +} + +/** + * Regression pin — fixed by the open-attributed-range guard on `isDeletedFormatClear` + * (`previousUnattributedFormats` has the key → the fresh render skips the deleted marker, so + * the change render must not emit a clear there; mirrors `isAcceptedFormatClear`'s guard). + * MISSING-attribution class (inverse direction of the stale classes above — there the cache + * keeps an attribution the fresh render has dropped; here the cache DROPPED one the fresh + * render keeps). Minimized from fuzz-core seed 2 (drift at opIndex 24, 25 ops → 3): + * a *suggested format removal* (key → null) of a base-doc format was applied to the maintained + * cache without the attributed-removal marker that the fresh render produces: + * + * cached: a(em) | b ← removal applied, attribution LOST + * fresh : a(em) | b({format: {em: []}}) ← attributed suggested removal + * + * Load-bearing shape: the base format must extend *beyond* the removed char (em over 'ab', + * removal on 'b' only — base-formatting 'b' alone renders clean), and the suggestion must + * first *override* the base format value on that char with a different value ({x:1} vs {}; + * without the override, or with an equal-value override, or with override+removal spanning + * the whole base range, the removal renders clean). Both clientID orders reproduce; no + * accept/reject involved (deterministic, 100/100 without seeding Math.random). + */ +export const testRdtSuggestedRemovalOfOverriddenBaseFormatCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('ab').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // base em over all of 'ab' — must extend beyond the char the suggestion touches + bt.applyDelta(delta.create().retain(2, { em: {} }).done()) + // suggested override of the base format value on 'b' (different value is load-bearing) + st.applyDelta(delta.create().retain(1).retain(1, { em: { x: 1 } }).done()) + // suggested removal of em on 'b' → cache applies the removal but loses the attribution + st.applyDelta(delta.create().retain(1).retain(1, { em: null }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a suggested removal of an overridden base format') +} + +/** + * Regression pin — fixed by the same open-attributed-range guard on `isDeletedFormatClear` as + * the class above. Adjudicated core of lifecycle-fuzz class P5 ("post-clearCache re-drift", + * seeds 657/1131/2401): clearCache is NOT load-bearing — all three seeds drift identically with + * the clearCache op deleted, and a FRESH doc/renderer pair built via + * encodeStateAsUpdate/applyUpdate to the pre-clearCache state drifts identically on the same + * final op. The class collapses into the core no-lifecycle overlapping same-key format drift + * family (missing-attribution direction): a suggested unformat of a base-doc format, staged + * left-to-right in two steps, lost the second step's unformat attribution in the maintained + * cache: + * + * cached: a({format:{em:[]}}) | b(no attribution ← MISSING) + * fresh : ab({format:{em:[]}}) + * + * Load-bearing: the two-step left-to-right staging (a single merged retain(2,{em:null}) renders + * clean, and unformatting 'b' before 'a' renders clean) and the base format arriving as ONE op + * over both chars (two 1-char base formats + a merged suggested unformat renders clean). + * Key (em/strong), base format value ({} vs {x:1}) and the clientID order are NOT load-bearing + * (drifts identically with base=0/sugg=1 and base=1/sugg=0). With a 3-char doc the same ops + * additionally leave a STALE {format:{em:[]}} on the still-formatted trailing char, i.e. both + * catalogued drift directions come from this walk defect. + */ +export const testRdtStagedSuggestedUnformatOfBaseFormatCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('ab').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // base em over all of 'ab' — in suggestion mode a base-doc format is not itself a suggestion + bt.applyDelta(delta.create().retain(2, { em: {} }).done()) + // suggested unformat of the same key, staged left-to-right: first 'a' — the cache is still + // clean here, 'a' carries {format:{em:[]}} + st.applyDelta(delta.create().retain(1, { em: null }).done()) + // ... then 'b' — the second step drops the {format:{em:[]}} attribution for 'b' from the + // cache while the fresh render keeps it + st.applyDelta(delta.create().retain(1).retain(1, { em: null }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a staged suggested unformat of a base format') +} + +/** + * Regression pin — fixed by provenance tracking (`currentFormatsAttributed`) widening + * `isDeletedFormatClear` to committed enclosing values: the accept deletes the committed run's + * close marker, whose removal re-exposes the committed `strong={}`, and the old + * `currFormatVal == null` guard blocked the needed clear. (Lifecycle-fuzz class P2, 23/3000 + * seeds; minimized from seed 259's 9 ops to 3): ACCEPT-triggered cache drift — every step + * before the accept is consistent, and `acceptAllChanges()` itself corrupted the maintained + * `.delta`. Shape: a committed `strong` run + * ('a', present since the very first render) with a plain char ('b') right after it; a suggested + * `strong` on 'b' with the SAME key AND the SAME value as the committed run; accept-all. The + * accept commits the suggestion to base, and a fresh deep render shows one fully-committed + * unattributed run — but the accept's change render never clears the suggestion's format + * attribution on 'b', leaving the stale `{format:{strong:[]}}` in the cache forever: + * + * cached: a(strong) | b(strong, {format:{strong:[]}} ← STALE) + * fresh : ab(strong) + * + * Everything is load-bearing: same key (strong/em → clean), same value ({} vs {x:1} or null → + * clean), adjacency (a gap between the runs → clean), and order (committed run must precede the + * suggested char; suggesting on the char *before* the run, or on the run itself, renders clean). + * Both clientID orders reproduce. Related but distinct from + * testRdtAcceptingNodeInsertCacheDrift (that class leaks `{insert:[]}` into nested node content; + * this one leaks a `format` attribution on plain text, with a clean pre-accept state). + */ +export const testRdtAcceptAllOfSameValueSuggestedFormatCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + // committed strong on 'a', plain 'b' — present since the very first render + bt.applyDelta(delta.create().insert('a', { strong: {} }).insert('b').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // suggested strong on the adjacent 'b' — same key AND same value as the committed run + st.applyDelta(delta.create().retain(1).retain(1, { strong: {} }).done()) + // the suggestion itself renders consistently; the drift is accept-triggered + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'suggested format itself is consistent') + renderer.acceptAllChanges() + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after accepting a same-value suggested format extension') +} + +/** + * Regression pin — the long-documented reject-side provenance residual, fixed by the same + * `currentFormatsAttributed` provenance tracking as the accept class above (the failing render + * is the sugg-doc EVENT render — the reject's heal render receives an empty change set — and + * the deleted suggestion marker re-exposes the enclosing *committed* base strong, which the old + * `currFormatVal == null` guard could not see). Lifecycle-fuzzing class P1, 45 seeds; minimized + * from the 9-op seed-132 representative to 4 ops: a suggested strong on 'a' under a base strong + * over all of 'ab' — same key, with the base range extending strictly *past* the suggested + * span — then rejectAllChanges. The reject clears the suggestion, but the change render left + * the trailing 'b' still carrying the suggested-format attribution in the cache; the fresh + * render shows it unattributed: + * + * cached: a(strong) | b(strong, {format:{strong:[]}} ← STALE) + * fresh : ab(strong) + * + * Everything here is load-bearing: without the suggested format, without the base format, with + * a different suggested key (em), with the base range ending where the suggested span ends + * (full overlap), or with the sugg span at the end of the base range, it renders clean. The + * pre-reject cache is consistent — the residual appears only at the reject. The marker + * integration order is load-bearing in the opposite direction of the two-key siblings above: + * it reproduces with base.clientID < sugg.clientID and renders clean flipped. The reject flow + * does not echo structs back here (both clientIDs survive unreassigned), so the repro is + * Math.random-free: 100/100 identical failing runs without seeding. + */ +export const testRdtRejectAllOverlappingSameKeyFormatProvenanceResidual = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('ab').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // suggested strong on 'a' + st.applyDelta(delta.create().retain(1, { strong: {} }).done()) + // base strong over all of 'ab' — same key, extending strictly past the suggested span + bt.applyDelta(delta.create().retain(2, { strong: {} }).done()) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'consistent before the reject') + // reject the suggestion → the trailing 'b' keeps the stale suggested-format attribution + renderer.rejectAllChanges() + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after rejecting overlapping same-key formats') +} + +/** + * Regression pin — fixed by re-adding the reset key as an explicit clear when the ambient-copy + * install is forced by the all-null-leaves disjunct (installing after silently dropping the key + * would close its governance without the clear the fresh render's context-close implies): + * partial-accept cache drift when the accepted id range SPLITS a suggested format's + * open/close ContentFormat marker pair. Lifecycle-fuzz seed 648 (failIndex 14, 15 ops incl. an + * accept and a reject) minimized to 5 ops with no inserts/deletes at all. + * + * Shape: two different-key suggested formats on the same char 'A' of base text 'AB'. The sugg + * client's structs are exactly four ContentFormat markers — clock 0 = em open ({em:{}}), + * clock 1 = em close ({em:null}), clock 2 = strong open, clock 3 = strong close. + * `acceptChanges(createID(1,1), createID(1,1))` accepts ONLY the em CLOSE marker (clock 1), + * splitting the em pair. The accept itself still renders consistently — the residual is armed, + * not visible. The next suggested unformat of 'A' then corrupts the maintained cache: the + * incremental render lets the strong suggestion's format attribution run past its close marker + * onto the trailing 'B': + * + * cached: A(strong, {format:{strong:[]}}) | B({format:{strong:[]}} <- STALE) + * fresh : A(strong, {format:{strong:[]}}) | B + * + * Load-bearing (each verified): the accept range must contain the em close marker and NOT the + * em open marker (1-1, 1-2, 1-3 all drift; 0-0, 0-1 whole-pair, 2-2, 3-3 all clean) — the + * marker-pair split IS the trigger; a second suggested format with a different key and non-null + * value (same key em -> clean, strong:null -> clean); a trailing char after the formatted one + * (init 'A' alone -> clean, formatting the last char -> clean); the final op must be an + * unformat — em:{} reformat is clean, while em:null and strong:null both drift identically. + * Every step before the final unformat is consistent, including the accept. Both clientID + * orders fail byte-identically; the accept does not echo structs back (no Math.random clientID + * reassignment), so the repro is seed-free: 100/100 identical failing runs. Same stale- + * {format:{strong:[]}}-on-trailing-char symptom as the (fixed) + * testRdtRejectAllOverlappingSameKeyFormatProvenanceResidual pin, but a distinct class: no base + * format involved, the trigger is a marker-splitting PARTIAL accept, and the drift is deferred + * to a later unformat. + */ +export const testRdtSplitFormatMarkerPairPartialAcceptThenUnformatCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('AB').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // two different-key suggested formats on 'A' — sugg structs: clock 0/1 = em open/close, + // clock 2/3 = strong open/close + st.applyDelta(delta.create().retain(1, { em: {} }).done()) + st.applyDelta(delta.create().retain(1, { strong: {} }).done()) + // accept ONLY the em close marker (clock 1), splitting the em marker pair — the accept + // itself still renders consistently + renderer.acceptChanges(Y.createID(1, 1), Y.createID(1, 1)) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'consistent immediately after the marker-splitting accept') + // suggested unformat of 'A' → the cache leaks {format:{strong:[]}} onto the trailing 'B' + st.applyDelta(delta.create().retain(1, { em: null }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after unformatting under a split-accepted format marker pair') +} + +/** + * Regression pin — fixed by the same reset-branch clear re-add as the split-marker-pair class + * above (both are the marker-pair-splitting-accept walk shape). Minimized from lifecycle-fuzz + * seed 1034 (failIndex 15, 16 ops → 6): + * PARTIAL-ACCEPT-triggered LATENT cache drift, stale-attribution direction. A committed base + * `strong` over 'abc'; a suggested unformat (strong → null) of 'b' (sugg-client markers: + * clock 0 = open strong:null, clock 1 = close/restore strong:{}); a second suggested format + * with a DIFFERENT key (em:{}) on the same 'b' (clocks 2/3); then a partial + * `acceptChanges(id(1,1), id(1,1))` that accepts ONLY the unformat's range-END restore marker, + * skipping its open marker at clock 0. The accept itself still renders consistently — the + * corruption is latent — but the next suggested format edit over the region (removing the em) + * re-renders 'c' with a stale `{format:{strong:[]}}` the fresh deep render does not have: + * + * cached: a(strong) | b({format:{strong:[]}}) | c(strong, {format:{strong:[]}} ← STALE) + * fresh : a(strong) | b({format:{strong:[]}}) | c(strong) + * + * Load-bearing: the accept range must EXCLUDE clock 0 and include at least one later sugg + * marker — accept(1,1), accept(2,2) and accept(3,3) all drift identically, while accept(0,0), + * accept(0,3), acceptAll and reject(1,1) render clean; the second suggested format must exist + * BEFORE the accept and use a different key (dropping it, or using strong for both, renders + * clean — which op carries which key does not matter, swapped keys drift too); the final op + * must be a format CHANGE re-rendering the region (em:null on 'b' or 'c', or a base em:{} on + * 'b' — re-applying the same em:{}, inserts, deletes and clearCache all render clean); and the + * committed strong must extend both strictly before and strictly past 'b' (2-char docs 'ab'/'bc' + * and base ranges not covering 'a' or 'c' render clean). Both clientID orders drift identically; + * the accept does not echo structs back (clientIDs survive unreassigned), so the repro is + * Math.random-free: 100/100 identical failing runs without seeding, on both trees. Related to + * (but distinct from) the fixed testRdtAcceptAllOfSameValueSuggestedFormatCacheDrift: here + * acceptAll is CLEAN, the partial id range is essential, and the drift needs a second op after + * the accept. + */ +export const testRdtPartialAcceptSkippingUnformatOpenMarkerCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert('abc').done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // committed base strong over all of 'abc' — must extend both before and past 'b' + bt.applyDelta(delta.create().retain(3, { strong: {} }).done()) + // suggested unformat of the base strong on 'b' → sugg-client markers + // clock 0 (open, strong:null) and clock 1 (close/restore, strong:{}) + st.applyDelta(delta.create().retain(1).retain(1, { strong: null }).done()) + // second suggested format (different key) on the same 'b' → clocks 2 (em:{}) and 3 (em:null) + st.applyDelta(delta.create().retain(1).retain(1, { em: {} }).done()) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'consistent before the accept') + // PARTIAL accept of only clock 1 — the unformat's range-END restore marker — skipping the + // open marker at clock 0. The accept itself still renders consistently (latent corruption). + renderer.acceptChanges(Y.createID(1, 1), Y.createID(1, 1)) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'consistent after the partial accept') + // suggested removal of the em → the re-render leaves a stale {format:{strong:[]}} on 'c' + st.applyDelta(delta.create().retain(1).retain(1, { em: null }).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a format edit following a partial accept that skipped the unformat open marker') +} + +/** + * Regression pin — fixed by threading `fresh` onto fresh-format pieces (mode-0 retained path) + * so a fresh reset-to-previous marker emits the per-key attribution clear like a rendered + * marker. The removeMark class from y-prosemirror cohort-fuzz seeds 143 + * (drift at op #35), 2 (op #46) and 18 (op #63), which all collapse to this one 3-op + * skeleton (cross-checked: each seed's failing op is a no-suggestions user's removeMark, + * i.e. a BASE-doc unformat, over text inside a suggested-block-deleted paragraph, and each + * seed's drift is the same stale format-attribution shape). STALE-attribution class in the + * nested deleted-children render path: a base-doc partial unformat (strong: null) of a base + * format on text inside a paragraph that is block-deleted as a suggestion never lands in the + * maintained cache — the cached delta after the op is byte-identical to the pre-op state, + * while the fresh deep render drops the format attribution on the covered chars: + * + * cached: paragraph[ ab({format:{strong:[]},delete:[]}) ] ← stale strong:[] on 'b' + * fresh : paragraph[ a({format:{strong:[]},delete:[]}) | b({delete:[]}) ] + * + * Load-bearing: the suggested BLOCK delete of the paragraph embed (a flat-YText suggested + * delete renders clean, and a text-level delete inside the paragraph renders clean), the + * base-side provenance of format+unformat (a sugg-side format/unformat pair inside the + * deleted paragraph renders clean), the unformat being a REMOVAL (re-formatting 'b' to a + * different value {x:1} renders clean; a partial format with no prior format renders clean), + * and the partial unformat clearing the TRAILING part of the formatted run (a full-range + * unformat renders clean, unformatting only the leading 'a' renders clean, and a 1-char + * format with the unformat extending past its end renders clean — hence the two chars). + * NOT load-bearing: the format key (em drifts identically), the order of the block delete + * vs the base format (format-before-delete drifts identically), and the clientID order + * (both 0/1 and 1/0 drift identically). No accept/reject is involved, so no Math.random + * clientID reassignment can occur: 100/100 byte-identical failing runs without seeding. + * Fails identically on the unpatched baseline — a pre-existing class, disjoint from the + * seven flat-text pins above (this one needs the deleted-ContentType modify render path; + * likely the known "modify targeting a tombstoned child is skipped by the incremental + * change render" defect). + */ +export const testRdtBaseUnformatInsideBlockDeletedParagraphCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + bt.applyDelta(delta.create().insert([delta.create('paragraph', {}, 'ab')]).done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // suggested block delete of the whole paragraph + st.applyDelta(delta.create().delete(1).done()) + // base strong over both chars inside the (suggested-deleted) paragraph — renders as an + // attributed format on the deleted text; still consistent here + bt.applyDelta(delta.create().modify(delta.create('paragraph').retain(2, { strong: {} }).done()).done()) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'consistent before the unformat') + // base unformat of the TRAILING char only — fresh drops the strong:[] attribution on 'b', + // the maintained cache keeps it on all of 'ab' (the change never lands in the cache) + bt.applyDelta(delta.create().modify(delta.create('paragraph').retain(1).retain(1, { strong: null }).done()).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a base partial unformat inside a block-deleted paragraph') +} + +/** + * Regression pin — fixed by the same fresh-format `c.fresh` threading as the class above. + * Minimized from y-prosemirror fuzz seed 103 (cache + * drift at op #68: a no-suggestions user's deleteRange whose PM diff carried a `{em:null}` + * format clear into the base doc, `retain(2).delete(2).insert('tl',{strong}).retain(1,{em:null}) + * .delete(4)` inside a modify). The 6-user cohort, the ProseMirror layer and the 68-op history + * are NOT load-bearing — fresh docs + a fresh renderer rebuilt from the pre-op state snapshot + * drift identically, and the class reduces to 2 ops on a 1-paragraph doc: + * a base-doc format CLEAR (key → null) ending strictly *inside* the same-key base format run, + * applied INSIDE a suggestion-deleted paragraph (a whole-node tombstone, still rendered + * delete-attributed), never reaches the maintained cache. The change render re-stamps the + * ambient `{format:{em:[]}}` attribution over the run instead of emitting the clear (and fires + * 'delta' twice for the one base transaction); a fresh deep render is correct: + * + * cached: ab({format:{em:[]}, delete:[]}) ← clear LOST + * fresh : a({format:{em:[]}, delete:[]}) | b({delete:[]}) + * + * Load-bearing shape: the WHOLE-NODE suggestion-delete (a text-level suggestion-delete of the + * same chars renders clean, as does every flat-text variant), the format CLEAR direction (a + * format add inside the tombstone renders clean — the green + * testRdtDeltaFormatThroughDeletedParent contract), and the run extending before the cleared + * char (em on 'b' alone, clearing the leading char, or a whole-run single-char clear all render + * clean; clearing a middle char of 'abc' drifts too). Key (em/strong) and format value ({} vs + * true) are not load-bearing. Both clientID orders drift identically (100/100 unseeded, no + * accept/reject involved). + */ +export const testRdtBaseFormatClearInsideSuggestionDeletedParagraphCacheDrift = () => { + const base = new Y.Doc({ gc: false }) + base.clientID = 0 + const sugg = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sugg.clientID = 1 + const renderer = Y.createDiffRenderer(base, sugg, { attributions: Y.createContentMap() }) + renderer.suggestionMode = true + const bt = base.get('t') + const st = sugg.get('t') + // base paragraph with an em run over 'ab' + bt.applyDelta(delta.create().insert([delta.create('paragraph').insert('ab', { em: {} })]).done()) + st.useRenderer(renderer) + t.assert(st.delta != null) // materialize the maintained cache + // suggestion-delete the whole paragraph (stays rendered as an attributed tombstone) + st.applyDelta(delta.create().delete(1).done()) + t.assert(st.delta.equals(st.toDelta({ deep: true })), 'cache consistent after the suggestion delete') + // base clears em on 'b' only — a clear ending strictly inside the em run, inside the tombstone + bt.applyDelta(delta.create().modify(delta.create().retain(1).retain(1, { em: null })).done()) + const cached = st.delta + const fresh = st.toDelta({ deep: true }) + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render after a base format clear inside a suggestion-deleted paragraph') +} + +/** + * Fingerprint-memo pin for the SECOND in-place cache-patch site: the renderer-overlay path + * (`typeApplyRendererChange` in src/ynode.js) — patches the maintained `.delta` via `apply` on a + * renderer 'change' event, WITHOUT a Y transaction on this doc (the fuzz pins only cover the + * per-transaction path). Fingerprints are memoized on the live cache before each step, exactly + * like a y-sync consumer; every overlay patch must invalidate them. Text-only ops on purpose + * (accept flows around nested node inserts have separately pinned structural quirks — see e.g. + * testRdtAcceptAllOfSameValueSuggestedFormatCacheDrift — that would contaminate this memo pin). + */ +export const testRdtFingerprintMemoOverlayCacheDrift = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attributions: Y.createContentMap() }) + doc.get('root').applyDelta(delta.create().insert('base content').done()) + const ynode = suggestionDoc.get('root') + ynode.useRenderer(renderer) + t.assert(ynode.delta.fingerprint != null) // materialize the cache AND memoize (the y-sync consumer step) + renderer.suggestionMode = true + ynode.applyDelta(delta.create().retain(2).delete(3).done()) // suggestion delete + assertDeltaCacheFresh(ynode, 'after suggestion delete') + ynode.format(0, 2, { bold: true }) // suggested format + assertDeltaCacheFresh(ynode, 'after suggested format') + // accept fires 'change' -> typeApplyRendererChange -> in-place apply on the memoized cache + renderer.acceptAllChanges() + assertDeltaCacheFresh(ynode, 'after acceptAllChanges (renderer overlay patch)') +} + +/** + * + * + * @param {t.TestCase} _tc + */ +export const testAttributionRendererActiveChangesInDiff = _tc => { + const ydoc = new Y.Doc({ gc: false }) + const ytext = ydoc.get() + ytext.insert(0, 'hella') + const state1 = Y.createContentIdsFromDoc(ydoc, true) + ytext.applyDelta(delta.create().retain(4).delete(1).insert('o!').done()) + const state2 = Y.createContentIdsFromDoc(ydoc, true) + ytext.applyDelta(delta.create().retain(5).delete(1).insert(' world').done()) + // state3 captures the *visible* content (`insertsContainDeletes: false` → inserts already exclude + // deletes). Using it both as the intersection target and as `renderedContent` means: a change that + // was inserted-then-deleted by state3 (the '!' at clock 6) is no longer an active insert and drops + // out, and state3's deletions (the 'a' at clock 4) aren't "restored" so they keep their delete + // attribution instead of re-rendering as inserts. + const state3 = Y.createContentIdsFromDoc(ydoc, false) + ytext.applyDelta(delta.create().delete(11).insert('42').done()) + + // render the active changes that happened between state 1 and 2 within state 3. + const attrs = Y.createContentMapFromContentIds(Y.intersectContentIds(Y.excludeContentIds(state2, state1), state3), [], []) + const renderer = Y.createAttributionsRenderer(attrs, { renderedContent: state3.inserts }) + + const attrDelta = ytext.toDelta({ renderer }) + console.log(attrDelta.toJSON()) + t.compare(attrDelta, delta.create().insert('hell').insert('a', null, { delete: [] }).insert('o', null, { insert: [] }).insert(' world').done()) +} diff --git a/tests/compatibility.tests.js b/tests/compatibility.tests.js new file mode 100644 index 0000000..852eb13 --- /dev/null +++ b/tests/compatibility.tests.js @@ -0,0 +1,69 @@ +/** + * Testing if encoding/decoding compatibility and integration compatibility is given. + * We expect that the document always looks the same, even if we upgrade the integration algorithm, or add additional encoding approaches. + * + * The v1 documents were generated with Yjs v13.2.0 based on the randomisized tests. + */ + +import * as Y from '../src/index.js' +import * as t from 'lib0/testing' +import * as buffer from 'lib0/buffer' +import * as s from 'lib0/schema' +import * as delta from 'lib0/delta' +import * as error from 'lib0/error' + +/** + * @param {Y.Node} ynode + */ +const ynodeToJSON = ynode => ynode.toJSON()?.children?.map(x => s.$objectAny.check(x) ? (x.attrs || x.children) : x) + +/** + * @param {t.TestCase} _tc + */ +export const testArrayCompatibilityV1 = _tc => { + const oldDoc = 'BV8EAAcBBWFycmF5AAgABAADfQF9An0DgQQDAYEEAAEABMEDAAQAAccEAAQFASEABAsIc29tZXByb3ACqAQNAX0syAQLBAUBfYoHwQQPBAUBwQQQBAUByAQRBAUBfYoHyAQQBBEBfY0HyAQTBBEBfY0HyAQUBBEBfY0HyAQVBBEBfY0HyAQQBBMBfY4HyAQXBBMBfY4HwQQYBBMBxwQXBBgACAAEGgR9AX0CfQN9BMEBAAEBAQADxwQLBA8BIQAEIwhzb21lcHJvcAKoBCUBfSzHBBkEEwEhAAQnCHNvbWVwcm9wAqgEKQF9LMcCAAMAASEABCsIc29tZXByb3ACqAQtAX0syAEBAQIBfZMHyAQvAQIBfZMHwQEGAQcBAAPBBDEBBwEABMcBGQEVAAgABDoEfQF9An0DfQTHAAgADgAIAAQ/BH0BfQJ9A30ExwQYBBkACAAERAR9AX0CfQN9BMcEIwQPASEABEkIc29tZXByb3ACqARLAX0swQAKAAkBxwEZBDoACAAETgR9AX0CfQN9BMcEEAQXAAgABFMEfQF9An0DfQTHAxsDHAAIAARYBH0BfQJ9A30ExwECAQ0BIQAEXQhzb21lcHJvcAKoBF8BfSzHAQQBBQAIAARhBH0BfQJ9A30ExwABAAYBIQAEZghzb21lcHJvcAKoBGgBfSzHAywDLQEhAARqCHNvbWVwcm9wAqgEbAF9LMcCCgMPASEABG4Ic29tZXByb3ACqARwAX0sxwMfAQABIQAEcghzb21lcHJvcAKoBHQBfSzHABcAGAEhAAR2CHNvbWVwcm9wAqgEeAF9LMcCEwMfAAgABHoEfQF9An0DfQTHARYBFwAIAAR/BH0BfQJ9A30ExwAIBD8BIQAEhAEIc29tZXByb3ACqASGAQF9LMcAGQAPAAgABIgBBH0BfQJ9A30ExwMBAScACAAEjQEEfQF9An0DfQTHAB4CDgEhAASSAQhzb21lcHJvcAKoBJQBAX0syAErAR4EfYQIfYQIfYQIfYQIxwB7AHwBIQAEmgEIc29tZXByb3ACqAScAQF9LMgBRgIrA32ICH2ICH2ICMgAEgAIAn2KCH2KCHADAAEBBWFycmF5AYcDAAEhAAMBCHNvbWVwcm9wAqgDAwF9LIEDAQEABIEDBQEABEECAAHIAw8CAAF9hwfIAxACAAF9hwfBAxECAAHHAAEAAgEhAAMTCHNvbWVwcm9wAqgDFQF9LIEEAAKIAxgBfYwHyAMPAxABfY8HwQMaAxAByAMbAxABfY8HyAMPAxoBfZAHyAMdAxoBfZAHxwACAw8BIQADHwhzb21lcHJvcAKoAyEBfSzHAxoDGwEhAAMjCHNvbWVwcm9wAqgDJQF9LMcCAAMAASEAAycIc29tZXByb3ACqAMpAX0swQMQAxEByAMrAxEBfZIHyAMsAxEBfZIHyAMtAxEBfZIHwQMYAxkBAATIAQYBBwF9lAfIAzQBBwF9lAfHAQcELwAIAAM2BH0BfQJ9A30EyAEBAR4CfZUHfZUHyAMsAy0DfZcHfZcHfZcHxwQTBBQBIQADQAhzb21lcHJvcAKoA0IBfSxIAAACfZgHfZgHyANFAAABfZgHxwEEAQUACAADRwR9AX0CfQN9BMgDQAQUAX2ZB8EDTAQUAscABgIXASEAA08Ic29tZXByb3ACqANRAX0syAM/Ay0BfZwHyAMfAQABfZ0HxwM2BC8ACAADVQR9AX0CfQN9BMcDRQNGASEAA1oIc29tZXByb3ACqANcAX0sxwMPAx0BIQADXghzb21lcHJvcAKoA2ABfSzIAQgBBgF9pAfIAQQDRwN9pwd9pwd9pwfIAA8AEAJ9rAd9rAfHAAAAAwAIAANoBH0BfQJ9A30EyAMQAysDfbIHfbIHfbIHxwQxAQcACAADcAR9AX0CfQN9BMcBAAQfASEAA3UIc29tZXByb3ACqAN3AX0syAM/A1MBfbUHyAN5A1MCfbUHfbUHyAMtAy4DfbcHfbcHfbcHyAACAhMCfbkHfbkHyAOAAQITAX25B8cBKwM7AAgAA4IBBH0BfQJ9A30ExwEZARUBIQADhwEIc29tZXByb3ACqAOJAQF9LMcCHAQLAAgAA4sBBH0BfQJ9A30EyAQZBCcBfbsHyAOQAQQnAn27B327B8cDkAEDkQEBIQADkwEIc29tZXByb3ACqAOVAQF9LMcDaAADAAgAA5cBBH0BfQJ9A30ExwN5A3oACAADnAEEfQF9An0DfQTHA4sBBAsACAADoQEEfQF9An0DfQTHA5MBA5EBASEAA6YBCHNvbWVwcm9wAqgDqAEBfSzHAAADaAAIAAOqAQR9AX0CfQN9BMgADgAZA328B328B328B8gECwQjBH2CCH2CCH2CCH2CCMcDLQN8ASEAA7YBCHNvbWVwcm9wAqgDuAEBfSzHBAoEAAAIAAO6AQR9AX0CfQN9BMgDgAEDgQECfYUIfYUIWgIAAQEFYXJyYXkBAARHAgAACAACBQR9AX0CfQN9BMECBQIAAQADwQIFAgoBAATBAAICBQEAA8cABgAHAAgAAhcEfQF9An0DfQTHAxkECwEhAAIcCHNvbWVwcm9wAqgCHgF9LMcABAAFASEAAiAIc29tZXByb3ACqAIiAX0syAAIAA4BfZYHyAMRAxIBfZoHxwMdAx4ACAACJgR9AX0CfQN9BMcEFgQRAAgAAisEfQF9An0DfQTHBAoEAAAIAAIwBH0BfQJ9A30EyAAOABkDfaAHfaAHfaAHxwEFACIACAACOAR9AX0CfQN9BMcDJwQrAAgAAj0EfQF9An0DfQTHAhcABwAIAAJCBH0BfQJ9A30EyAEABB8CfaUHfaUHxwQrAwABIQACSQhzb21lcHJvcAKoAksBfSzHBCcEEwAIAAJNBH0BfQJ9A30ExwMbAxwACAACUgR9AX0CfQN9BMcEJwJNASEAAlcIc29tZXByb3ACqAJZAX0sxwQvBDAACAACWwR9AX0CfQN9BMcCPQQrASEAAmAIc29tZXByb3ACqAJiAX0sxwAYAycBIQACZAhzb21lcHJvcAKoAmYBfSzIAQEBHgJ9swd9swfIAmQDJwN9tAd9tAd9tAfHAkkDAAAIAAJtBH0BfQJ9A30ExwJkAmoACAACcgR9AX0CfQN9BMcCJgMeAAgAAncEfQF9An0DfQTHAiUDEgEhAAJ8CHNvbWVwcm9wAqgCfgF9LMgBFwEYBH24B324B324B324B8cBAQJoASEAAoQBCHNvbWVwcm9wAqgChgEBfSzHAkkCbQAIAAKIAQR9AX0CfQN9BMcCSAQfASEAAo0BCHNvbWVwcm9wAqgCjwEBfSzIAQYEMQR9vgd9vgd9vgd9vgfHAAAAAwEhAAKVAQhzb21lcHJvcAKoApcBAX0sxwJNBBMBIQACmQEIc29tZXByb3ACqAKbAQF9LMcCJgJ3ASEAAp0BCHNvbWVwcm9wAqgCnwEBfSzHAAEABgAIAAKhAQR9AX0CfQN9BMgCjQEEHwF9gwjIAyMDGwF9hgjHBF0BDQAIAAKoAQR9AX0CfQN9BMcDPAEeAAgAAq0BBH0BfQJ9A30EagEAAQEFYXJyYXkByAEAAwABfYMHyAEBAwABfYMHwQECAwAByAEBAQIBfYYHyAEEAQIBfYYHyAEFAQIBfYYHwQEGAQIBxwEFAQYACAABCAR9AX0CfQN9BMEBAgEDAQAEwQEFAQgByAESAQgBfYsHyAETAQgBfYsHyAEUAQgBfYsHgQQAAYEBFgGIARcBfZEHxwEUARUACAABGQR9AX0CfQN9BMcBAQEEAAgAAR4EfQF9An0DfQTHARQBGQEhAAEjCHNvbWVwcm9wAqgBJQF9LMEDAQMFAQADxwEBAR4BIQABKwhzb21lcHJvcAKoAS0BfSzHAgUAHgEhAAEvCHNvbWVwcm9wAqgBMQF9LMcECwQjASEAATMIc29tZXByb3ACqAE1AX0sxwMtAy4ACAABNwR9AX0CfQN9BMcDDwMdAAgAATwEfQF9An0DfQTHAQIBDQAIAAFBBH0BfQJ9A30ExwQWBBEBIQABRghzb21lcHJvcAKoAUgBfSzBABgDJwHIAUoDJwF9nwfHBBcEGgAIAAFMBH0BfQJ9A30ExwEABB8BIQABUQhzb21lcHJvcAKoAVMBfSzIAx0DHgJ9oQd9oQfIARkBFQF9ogfIAhwECwN9qAd9qAd9qAfIAxEDEgF9qgfIBAABFgJ9qwd9qwfIABAAEQF9rQfIAV4AEQF9rQfIAV8AEQJ9rQd9rQfIAV4BXwR9rwd9rwd9rwd9rwfIABABXgN9sAd9sAd9sAfIAWgBXgF9sAfHBA8EEAAIAAFqBH0BfQJ9A30ExwQYBBkBIQABbwhzb21lcHJvcAKoAXEBfSzHAAcAEgEhAAFzCHNvbWVwcm9wAqgBdQF9LEcAAAAIAAF3BH0BfQJ9A30ExwMPATwBIQABfAhzb21lcHJvcAKoAX4BfSzIAXwBPAJ9ugd9ugfBAYEBATwCxwFoAWkACAABhAEEfQF9An0DfQTHAV8BYAAIAAGJAQR9AX0CfQN9BMcADgAZASEAAY4BCHNvbWVwcm9wAqgBkAEBfSzIAx8BAAF9vQfIAZIBAQABfb0HyAQVBBYCfb8Hfb8HxwQaBBgBIQABlgEIc29tZXByb3ACqAGYAQF9LMgBHgEEA32ACH2ACH2ACMcEGAFvAAgAAZ0BBH0BfQJ9A30ExwMTAAIBIQABogEIc29tZXByb3ACqAGkAQF9LMcBkgEBkwEBIQABpgEIc29tZXByb3ACqAGoAQF9LMcBnAEBBAEhAAGqAQhzb21lcHJvcAKoAawBAX0syAF8AYABBH2HCH2HCH2HCH2HCMgBpgEBkwEDfYkIfYkIfYkIYQAAAQEFYXJyYXkBiAAAAX2AB4EAAQHBAAAAAQLIAAQAAQF9gQfIAAEAAgF9hAfIAAYAAgF9hAfIAAcAAgF9hAfBAAgAAgHBAAgACQEAA8gACAAKAX2FB8EADgAKAcgADwAKAX2FB8gAEAAKAX2FB8cABwAIAAgAABIEfQF9An0DfQTIAgADAAF9iQfIABcDAAF9iQfHAA4ADwAIAAAZBH0BfQJ9A30ExwIFAgABIQAAHghzb21lcHJvcAKoACABfSzHAQUBEgEhAAAiCHNvbWVwcm9wAqgAJAF9LMcAHgIOAAgAACYEfQF9An0DfQTHBBQEFQAIAAArBH0BfQJ9A30ExwAAAAMACAAAMAR9AX0CfQN9BMcBBQAiAAgAADUEfQF9An0DfQTIAx4DGgN9mwd9mwd9mwfHAhcABwAIAAA9BH0BfQJ9A30ExwEYAxcBIQAAQghzb21lcHJvcAKoAEQBfSzBACIBEgEABMcDDwMdASEAAEsIc29tZXByb3ACqABNAX0sxwQYBBkBIQAATwhzb21lcHJvcAKoAFEBfSzHACIARgAIAABTBH0BfQJ9A30ExwMdAx4BIQAAWAhzb21lcHJvcAKoAFoBfSzIAB4AJgF9owfHAzYELwAIAABdBH0BfQJ9A30EyAQwAQIDfaYHfaYHfaYHyABkAQIBfakHyAAXABgCfa4Hfa4HxwQjBA8BIQAAaAhzb21lcHJvcAKoAGoBfSzHAycEKwAIAABsBH0BfQJ9A30ExwABAAYACAAAcQR9AX0CfQN9BMcAZABlAAgAAHYEfQF9An0DfQTIAAcAEgF9sQfIAHsAEgN9sQd9sQd9sQfIAA8AEAF9tgfHARMBFAAIAACAAQR9AX0CfQN9BMcDIwMbAAgAAIUBBH0BfQJ9A30ExwEVAQgACAAAigEEfQF9An0DfQTHAIoBAQgBIQAAjwEIc29tZXByb3ACqACRAQF9LMcCFwA9AAgAAJMBBH0BfQJ9A30ExwEYAEIACAAAmAEEfQF9An0DfQTHAzQDNQEhAACdAQhzb21lcHJvcAKoAJ8BAX0sxwAQABEBIQAAoQEIc29tZXByb3ACqACjAQF9LMgAgAEBFAF9gQjHBBYEEQEhAACmAQhzb21lcHJvcAKoAKgBAX0sxwAHAHsACAAAqgEEfQF9An0DfQQFABAAAQIDCQUPAR8CIwJDAkYFTAJQAlkCaQKQAQKeAQKiAQKnAQICDgAFCg0dAiECSgJYAmECZQJ9AoUBAo4BApYBApoBAp4BAgQUBAcMAhACGQEfBCQCKAIsAjEJSgJNAV4CZwJrAm8CcwJ3AoUBApMBApsBAgMWAAECAgULEgEUAhcCGwEgAiQCKAIrAS8FQQJNAlACWwJfAnYCiAEClAECpwECtwECARYAAQMBBwENBhYCJAInBCwCMAI0AkcCSgFSAnACdAJ9AoIBAo8BApcBAqMBAqcBAqsBAg==' + const oldVal = JSON.parse('[[1,2,3,4],472,472,{"someprop":44},472,[1,2,3,4],{"someprop":44},[1,2,3,4],[1,2,3,4],[1,2,3,4],{"someprop":44},449,448,[1,2,3,4],[1,2,3,4],{"someprop":44},452,{"someprop":44},[1,2,3,4],[1,2,3,4],[1,2,3,4],[1,2,3,4],452,[1,2,3,4],497,{"someprop":44},497,497,497,{"someprop":44},[1,2,3,4],522,522,452,470,{"someprop":44},[1,2,3,4],453,{"someprop":44},480,480,480,508,508,508,[1,2,3,4],[1,2,3,4],502,492,492,453,{"someprop":44},496,496,496,[1,2,3,4],496,493,495,495,495,495,493,[1,2,3,4],493,493,453,{"someprop":44},{"someprop":44},505,505,517,517,505,[1,2,3,4],{"someprop":44},509,{"someprop":44},521,521,521,509,477,{"someprop":44},{"someprop":44},485,485,{"someprop":44},515,{"someprop":44},451,{"someprop":44},[1,2,3,4],516,516,516,516,{"someprop":44},499,499,469,469,[1,2,3,4],[1,2,3,4],512,512,512,{"someprop":44},454,487,487,487,[1,2,3,4],[1,2,3,4],454,[1,2,3,4],[1,2,3,4],{"someprop":44},[1,2,3,4],459,[1,2,3,4],513,459,{"someprop":44},[1,2,3,4],482,{"someprop":44},[1,2,3,4],[1,2,3,4],459,[1,2,3,4],{"someprop":44},[1,2,3,4],484,454,510,510,510,510,468,{"someprop":44},468,[1,2,3,4],[1,2,3,4],[1,2,3,4],[1,2,3,4],467,[1,2,3,4],467,486,486,486,[1,2,3,4],489,451,[1,2,3,4],{"someprop":44},[1,2,3,4],[1,2,3,4],{"someprop":44},{"someprop":44},483,[1,2,3,4],{"someprop":44},{"someprop":44},{"someprop":44},{"someprop":44},519,519,519,519,506,506,[1,2,3,4],{"someprop":44},464,{"someprop":44},481,481,[1,2,3,4],{"someprop":44},[1,2,3,4],464,475,475,475,463,{"someprop":44},[1,2,3,4],518,[1,2,3,4],[1,2,3,4],463,455,498,498,498,466,471,471,471,501,[1,2,3,4],501,501,476,{"someprop":44},466,[1,2,3,4],{"someprop":44},503,503,503,466,455,490,474,{"someprop":44},457,494,494,{"someprop":44},457,479,{"someprop":44},[1,2,3,4],500,500,500,{"someprop":44},[1,2,3,4],[1,2,3,4],{"someprop":44},{"someprop":44},{"someprop":44},[1,2,3,4],[1,2,3,4],{"someprop":44},[1,2,3,4],[1,2,3,4],[1,2,3,4],[1,2,3],491,491,[1,2,3,4],504,504,504,504,465,[1,2,3,4],{"someprop":44},460,{"someprop":44},488,488,488,[1,2,3,4],[1,2,3,4],{"someprop":44},{"someprop":44},514,514,514,514,{"someprop":44},{"someprop":44},{"someprop":44},458,[1,2,3,4],[1,2,3,4],462,[1,2,3,4],[1,2,3,4],{"someprop":44},462,{"someprop":44},[1,2,3,4],{"someprop":44},[1,2,3,4],507,{"someprop":44},{"someprop":44},507,507,{"someprop":44},{"someprop":44},[1,2,3,4],{"someprop":44},461,{"someprop":44},473,461,[1,2,3,4],461,511,511,461,{"someprop":44},{"someprop":44},520,520,520,[1,2,3,4],458]') + const doc = new Y.Doc() + Y.applyUpdate(doc, buffer.fromBase64(oldDoc)) + t.compare(ynodeToJSON(doc.get('array')), oldVal) +} + +/** + * @param {t.TestCase} _tc + */ +export const testMapDecodingCompatibilityV1 = _tc => { + const oldDoc = 'BVcEAKEBAAGhAwEBAAShBAABAAGhBAECoQEKAgAEoQQLAQAEoQMcAaEEFQGhAiECAAShAS4BoQQYAaEEHgGhBB8BoQQdAQABoQQhAaEEIAGhBCMBAAGhBCUCoQQkAqEEKAEABKEEKgGhBCsBoQQwAQABoQQxAaEEMgGhBDQBAAGhBDYBoQQ1AQAEoQQ5AQABoQQ4AQAEoQM6AQAEoQRFAaEESgEAAaEESwEABKEETQGhBEABoQRSAgABoQRTAgAEoQRVAgABoQReAaEEWAEABKEEYAEAAaEEZgKhBGECAAShBGsBAAGhAaUBAgAEoQRwAgABoQRzAQAEoQR5AQABoQSAAQEABKEEggEBAAGhBIcBAwABoQGzAQEAAaEEjQECpwHMAQAIAASRAQR9AX0CfQN9BGcDACEBA21hcAN0d28BoQMAAQAEoQMBAQABoQMGAQAEIQEDbWFwA29uZQOhBAEBAAShAw8CoQMQAaEDFgEAAaEDFwEAAaEDGAGhAxwBAAGhAx0BoQIaBAAEoQMjAgABoQMpAQABoQMfAQABoQMrAQABoQMvAaEDLQEAAaEDMQIABKEDNQGhAzIBoQM6AaEDPAGhBCMBAAGhAU8BAAGhA0ADoQJCAQABoQNEAgABoQNFAgAEoQJEAQABoQNLAaEEQAEABKEESgEAAaEDWAGhA1MBAAGhA1oBAAGhA10DoQNbAQABoQNhAwABoQNiAQAEoQNmAaEDaAWhA20BAAShA3IBAAGhA3MBAAShA3gBoQN6A6EDfwEAAaEDgwEBAAShA4UBAgABoQOLAQGhA4IBAaEDjQEBoQOOAQEAAaEDjwEBAAShA5ABAaEDkgEBoQOXAQEABKEDmQEBAAGhA5gBAQABoQOgAQEAAaEDngEBaQIAIQEDbWFwA3R3bwGhAwABoQEAAQABoQIBAQAEoQIEAaEDAQKhAQwDAAShAg4BAAShAhMBoQQJBAABoQQVAQABoQIeAaECHAGhBBgBAAShAiICAAShBB4BAAShBB8BAAGhAzwBAAGhBCMCoQM9AqEDPgEAAaECOQEABKECPAGhAkEBAAGhAjoBAAGhAkIBAAShAkQBAAShAksBAAGhA0UCAAShAlMCoQJQAQAEoQJZAaECWgEAAaECYAKhAl8BAAShAmMCAAGhAmoBoQJkAgAEoQRSAQABoQJzAQAEoQRTAQAEoQJ6AQAEoQJ1AQABoQKEAQEABKEChgEBoQJ/AgABoQKLAQEABKECjwECAAGhApUBAQABoQKXAQGhAo0BAQABoQKaAQGhApkBAQABoQKcAQEAAaECnwEBAAShAqEBAaECnQEBAAShAqYBAaECpwEBAAGhAqwBAQABoQKtAQEABKECrwECAAF5AQAhAQNtYXADb25lASEBA21hcAN0d28CAAGhAQABoQMAAaEBBAEAAaEBBQGhAQYBoQMPAaEEAQGhAQoBoQELAaEBDAEABKEBDgEAAaEBDQEABKEBFQEABKEDHAKhBBUBAAShASECAAShAScBoQQWAwAEoQIhAgABoQEvAaECIgEABKEBOAEAAaEBNwEAAaEBPwGhAzoBAAShAUIBoQQjAQABoQFIAQABoQFKAaEDPgEAAaECOgEAAaEBTwIAAaEBUgEAAaECQQGhAVQCoQFWAgABoQFYAQAEoQFcAqEBWgEAAaEBYgShAWMBAAGhAWgBAAGhAWkCAAGhAW4BAAGhAWsBAAShAXABAAShAXcCAAShAX0BAAShAYIBAaEBcgEAAaEBhwEBoQGIAQEABKEBigEBAAShAZABAQAEoQGLAQIABKEBlQEBAAShBGkEAAGhAagBAQAEoQRzAQABoQGvAQKhBHsBAAGhAbMBAgAEoQSAAQEAAaEBuwECAAGhAbYBAqEEiwEBAAShAcIBAQAEoQHHAQEABKEEkAEBpwHRAQEoAAHSAQdkZWVwa2V5AXcJZGVlcHZhbHVloQHMAQFiAAAhAQNtYXADb25lAwABoQACASEBA21hcAN0d28CAAShAAQBoQAGAaEACwEAAaEADQIABKEADAEABKEAEAEABKEAGgEABKEAHwEABKEAFQGhACQBAAGhACoBoQApAaEALAGhAC0BoQAuAaEALwEAAaEAMAIAAaEANAEABKEAMQEABKEANgEAAaEAQAIAAaEAOwGhAEMCAAShAEcBAAShAEwBoQBFAQAEoQBRAQAEoQBXAqEAUgEABKEAXgIAAaEAZAKhAF0BoQBnAqEAaAEABKEAawGhAGoCoQBwAQABoQBzAQAEoQB1AQABoQB6AaEAcgGhAHwBAAShAH4BoQB9AgABoQCFAQEABKEAhwEBAAShAIwBAaEAgwEBAAShAJIBAQAEoQCXAQIABKEAkQEBAAGhAJ0BAQAEoQCiAQEABKEApAECAAGhAK8BAqEAqQEBAAGhALMBAQABBQABALcBAQIA0gHUAQEEAQCRAQMBAKUBAgEAuQE=' + // eslint-disable-next-line + const oldVal = /** @type {any} */ ({"one":{children:[1,2,3,4]},"two":{attrs: {"deepkey":"deepvalue"}}}) + const doc = new Y.Doc() + Y.applyUpdate(doc, buffer.fromBase64(oldDoc)) + t.compare(doc.get('map').toJSON().attrs, oldVal) +} + +/** + * @param {t.TestCase} _tc + */ +export const testTextDecodingCompatibilityV1 = _tc => { + const oldDoc = 'BS8EAAUBBHRleHRveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9RAQAATHBBAEEAAHBBAIEAAHEBAMEAAQxdXUKxQQCBANveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9xQMJBAFveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9xQMJBAlveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9xgMBAwIGaXRhbGljBHRydWXGBAsDAgVjb2xvcgYiIzg4OCLEBAwDAgExxAQNAwIBMsEEDgMCAsYEEAMCBml0YWxpYwRudWxsxgQRAwIFY29sb3IEbnVsbMQDAQQLATHEBBMECwIyOcQEFQQLCzl6anpueXdvaHB4xAQgBAsIY25icmNhcQrBAxADEQHGAR8BIARib2xkBHRydWXGAgACAQRib2xkBG51bGzFAwkECm97ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn3GARABEQZpdGFsaWMEdHJ1ZcYELQERBWNvbG9yBiIjODg4IsYBEgETBml0YWxpYwRudWxsxgQvARMFY29sb3IEbnVsbMYCKwIsBGJvbGQEdHJ1ZcYCLQIuBGJvbGQEbnVsbMYCjAECjQEGaXRhbGljBHRydWXGAo4BAo8BBml0YWxpYwRudWxswQA2ADcBxgQ1ADcFY29sb3IGIiM4ODgixgNlA2YFY29sb3IEbnVsbMYDUwNUBGJvbGQEdHJ1ZcQEOANUFjEzMTZ6bHBrbWN0b3FvbWdmdGhicGfGBE4DVARib2xkBG51bGzGAk0CTgZpdGFsaWMEdHJ1ZcYEUAJOBWNvbG9yBiIjODg4IsYCTwJQBml0YWxpYwRudWxsxgRSAlAFY29sb3IEbnVsbMYChAEChQEGaXRhbGljBHRydWXGBFQChQEFY29sb3IGIiM4ODgixgKGAQKHAQZpdGFsaWMEbnVsbMYEVgKHAQVjb2xvcgRudWxsxAMpAyoRMTMyMWFwZ2l2eWRxc2pmc2XFBBIDAm97ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn0zAwAEAQR0ZXh0AjEyhAMBAzkwboQDBAF4gQMFAoQDBwJyCsQDBAMFBjEyOTd6bcQDDwMFAXbEAxADBQFwwQMRAwUBxAMSAwUFa3pxY2rEAxcDBQJzYcQDGQMFBHNqeQrBAxIDEwHBAAwAEAHEAA0ADgkxMzAyeGNpd2HEAygADgF5xAMpAA4KaGhlenVraXF0dMQDMwAOBWhudGsKxgMoAykEYm9sZAR0cnVlxAM5AykGMTMwNXJswQM/AykCxANBAykDZXlrxgNEAykEYm9sZARudWxsxAMzAzQJMTMwN3R2amllwQNOAzQCxANQAzQDamxoxANTAzQCZ3bEA1UDNAJsYsQDVwM0AmYKxgNBA0IEYm9sZARudWxswQNaA0ICxANcA0ICMDjBA14DQgLEA2ADQgEKxgNhA0IEYm9sZAR0cnVlxQIaAhtveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9wQA3ADgCwQNlADgBxANmADgKMTVteml3YWJ6a8EDcAA4AsQDcgA4BnJybXNjdsEDeAA4AcQCYgJjATHEA3oCYwIzMsQDfAJjCTRyb3J5d3RoccQDhQECYwEKxAOFAQOGARkxMzI1aW9kYnppenhobWxpYnZweXJ4bXEKwQN6A3sBxgOgAQN7BWNvbG9yBiIjODg4IsYDfAN9Bml0YWxpYwRudWxsxgOiAQN9BWNvbG9yBG51bGxSAgAEAQR0ZXh0ATGEAgACMjiEAgIBOYECAwKEAgUBdYQCBgJ0Y4QCCAJqZYECCgKEAgwBaoECDQGBAg4BhAIPAnVmhAIRAQrEAg4CDwgxMjkycXJtZsQCGgIPAmsKxgIGAgcGaXRhbGljBHRydWXGAggCCQZpdGFsaWMEbnVsbMYCEQISBml0YWxpYwR0cnVlxAIfAhIBMcECIAISAsQCIgISAzRoc8QCJQISAXrGAiYCEgZpdGFsaWMEbnVsbMEAFQAWAsQCKQAWATDEAioAFgEwxAIrABYCaHjEAi0AFglvamVldHJqaHjBAjYAFgLEAjgAFgJrcsQCOgAWAXHBAjsAFgHBAjwAFgHEAj0AFgFuxAI+ABYCZQrGAiUCJgZpdGFsaWMEbnVsbMQCQQImAjEzwQJDAiYCxAJFAiYIZGNjeGR5eGfEAk0CJgJ6Y8QCTwImA2Fwb8QCUgImAnRuxAJUAiYBcsQCVQImAmduwQJXAiYCxAJZAiYBCsYCWgImBml0YWxpYwR0cnVlxAI6AjsEMTMwM8QCXwI7A3VodsQCYgI7BmdhbmxuCsUCVQJWb3siaW1hZ2UiOiJodHRwczovL3VzZXItaW1hZ2VzLmdpdGh1YnVzZXJjb250ZW50LmNvbS81NTUzNzU3LzQ4OTc1MzA3LTYxZWZiMTAwLWYwNmQtMTFlOC05MTc3LWVlODk1ZTU5MTZlNS5wbmcifcECPAI9AcECPgI/AcYDFwMYBml0YWxpYwR0cnVlxgJsAxgFY29sb3IGIiM4ODgixgMZAxoGaXRhbGljBG51bGzGAm4DGgVjb2xvcgRudWxswQMQBCkBxAJwBCkKMTMwOXpsZ3ZqeMQCegQpAWfBAnsEKQLGBA0EDgZpdGFsaWMEbnVsbMYCfgQOBWNvbG9yBG51bGzEAn8EDgUxMzEwZ8QChAEEDgJ3c8QChgEEDgZoeHd5Y2jEAowBBA4Ca3HEAo4BBA4Ec2RydcQCkgEEDgRqcWljwQKWAQQOBMQCmgEEDgEKxgKbAQQOBml0YWxpYwR0cnVlxgKcAQQOBWNvbG9yBiIjODg4IsECaAI7AcQCCgEBFjEzMThqd3NramFiZG5kcmRsbWphZQrGA1UDVgRib2xkBHRydWXGA1cDWARib2xkBG51bGzGAEAAQQZpdGFsaWMEdHJ1ZcYCtwEAQQRib2xkBG51bGzEArgBAEESMTMyNnJwY3pucWFob3BjcnRkxgLKAQBBBml0YWxpYwRudWxsxgLLAQBBBGJvbGQEdHJ1ZRkBAMUCAgIDb3siaW1hZ2UiOiJodHRwczovL3VzZXItaW1hZ2VzLmdpdGh1YnVzZXJjb250ZW50LmNvbS81NTUzNzU3LzQ4OTc1MzA3LTYxZWZiMTAwLWYwNmQtMTFlOC05MTc3LWVlODk1ZTU5MTZlNS5wbmcifcQCCgILBzEyOTN0agrGABgAGQRib2xkBHRydWXGAA0ADgRib2xkBG51bGxEAgAHMTMwNnJ1cMQBEAIAAnVqxAESAgANaWtrY2pucmNwc2Nrd8QBHwIAAQrFBBMEFG97ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn3FAx0DBW97ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn3GAlICUwRib2xkBHRydWXGAlQCVQRib2xkBG51bGzGAnsCfAZpdGFsaWMEdHJ1ZcYBJQJ8BWNvbG9yBiIjODg4IsYBJgJ8BGJvbGQEbnVsbMQBJwJ8CjEzMTRweWNhdnXGATECfAZpdGFsaWMEbnVsbMYBMgJ8BWNvbG9yBG51bGzBATMCfAHFADEAMm97ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn3GADUANgZpdGFsaWMEdHJ1ZcEANwA4AcQAMgAzEzEzMjJybmJhb2tvcml4ZW52cArEAgUCBhcxMzIzbnVjdnhzcWx6bndsZmF2bXBjCsYDDwMQBGJvbGQEdHJ1ZR0AAMQEAwQEDTEyOTVxZnJ2bHlmYXDEAAwEBAFjxAANBAQCanbBAAwADQHEABAADQEywQARAA0ExAAVAA0DZHZmxAAYAA0BYcYCAwIEBml0YWxpYwR0cnVlwQAaAgQCxAAcAgQEMDRrdcYAIAIEBml0YWxpYwRudWxsxQQgBCFveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9xQJAABZveyJpbWFnZSI6Imh0dHBzOi8vdXNlci1pbWFnZXMuZ2l0aHVidXNlcmNvbnRlbnQuY29tLzU1NTM3NTcvNDg5NzUzMDctNjFlZmIxMDAtZjA2ZC0xMWU4LTkxNzctZWU4OTVlNTkxNmU1LnBuZyJ9xAQVBBYGMTMxMWtrxAIqAisIMTMxMnFyd3TEADECKwFixAAyAisDcnhxxAA1AisBasQANgIrAXjEADcCKwZkb3ZhbwrEAgAEKwMxMzHEAEAEKwkzYXhoa3RoaHXGAnoCewRib2xkBG51bGzFAEoCe297ImltYWdlIjoiaHR0cHM6Ly91c2VyLWltYWdlcy5naXRodWJ1c2VyY29udGVudC5jb20vNTU1Mzc1Ny80ODk3NTMwNy02MWVmYjEwMC1mMDZkLTExZTgtOTE3Ny1lZTg5NWU1OTE2ZTUucG5nIn3GAEsCewRib2xkBHRydWXEAl8CYBExMzE3cGZjeWhrc3JrcGt0CsQBHwQqCzEzMTliY2Nna3AKxAKSAQKTARUxMzIwY29oYnZjcmtycGpuZ2RvYwoFBAQCAg8CKQE1AQADEAESBBsCAwsGAhIBHgJAAk8CWwJfAmQDcQJ5AaABAQIOBAILAg4CIQIoAjcCPAJEAlgCagJwAXwClwEEngEBAQI0ATcB' + // eslint-disable-next-line + const oldVal = [{"insert":"1306rup"},{"insert":"uj","format":{"italic":true,"color":"#888"}},{"insert":"ikkcjnrcpsckw1319bccgkp\n"},{"insert":"\n1131","format":{"bold":true}},{"insert":"1326rpcznqahopcrtd","format":{"italic":true}},{"insert":"3axhkthhu","format":{"bold":true}},{"insert":"28"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"9"},{"insert":"04ku","format":{"italic":true}},{"insert":"1323nucvxsqlznwlfavmpc\nu"},{"insert":"tc","format":{"italic":true}},{"insert":"je1318jwskjabdndrdlmjae\n1293tj\nj1292qrmf"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"k\nuf"},{"insert":"14hs","format":{"italic":true}},{"insert":"13dccxdyxg"},{"insert":"zc","format":{"italic":true,"color":"#888"}},{"insert":"apo"},{"insert":"tn","format":{"bold":true}},{"insert":"r"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"gn\n"},{"insert":"z","format":{"italic":true}},{"insert":"\n121"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"291311kk9zjznywohpx"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"cnbrcaq\n"},{"insert":"1","format":{"italic":true,"color":"#888"}},{"insert":"1310g"},{"insert":"ws","format":{"italic":true,"color":"#888"}},{"insert":"hxwych"},{"insert":"kq","format":{"italic":true}},{"insert":"sdru1320cohbvcrkrpjngdoc\njqic\n"},{"insert":"2","format":{"italic":true,"color":"#888"}},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"90n1297zm"},{"insert":"v1309zlgvjx","format":{"bold":true}},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"g","format":{"bold":true}},{"insert":"1314pycavu","format":{"italic":true,"color":"#888"}},{"insert":"pkzqcj"},{"insert":"sa","format":{"italic":true,"color":"#888"}},{"insert":"sjy\n"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"xr\n"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"},{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}, {"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"1"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"1295qfrvlyfap201312qrwt"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"b1322rnbaokorixenvp\nrxq"},{"insert":"j","format":{"italic":true}},{"insert":"x","format":{"italic":true,"color":"#888"}},{"insert":"15mziwabzkrrmscvdovao\n0","format":{"italic":true}},{"insert":"hx","format":{"italic":true,"bold":true}},{"insert":"ojeetrjhxkr13031317pfcyhksrkpkt\nuhv1","format":{"italic":true}},{"insert":"32","format":{"italic":true,"color":"#888"}},{"insert":"4rorywthq1325iodbzizxhmlibvpyrxmq\n\nganln\nqne\n"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]},{"insert":"dvf"},{"insert":"ac","format":{"bold":true}},{"insert":"1302xciwa"},{"insert":"1305rl","format":{"bold":true}},{"insert":"08\n"},{"insert":"eyk","format":{"bold":true}},{"insert":"y1321apgivydqsjfsehhezukiqtt1307tvjiejlh"},{"insert":"1316zlpkmctoqomgfthbpg","format":{"bold":true}},{"insert":"gv"},{"insert":"lb","format":{"bold":true}},{"insert":"f\nhntk\njv1uu\n"},{"insert":[{"image":"https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png"}]}].map(x => ({ type: 'insert', ...x })) + const doc = new Y.Doc() + Y.applyUpdate(doc, buffer.fromBase64(oldDoc)) + + /** + * @param {Array<{insert?:string|Array,format?:{[K:string]:any},retain?:number,delete?:number}>} q + */ + const quillDeltaToDelta = q => { + const d = delta.create() + for (let i = 0; i < q.length; i++) { + const qd = q[i] + if (qd.insert != null) { + d.insert(qd.insert, qd.format || {}) + } else { + error.unexpectedCase() + } + } + return d.done() + } + t.compare(doc.get('text').toDelta(), quillDeltaToDelta(oldVal)) +} diff --git a/tests/delta.tests.js b/tests/delta.tests.js new file mode 100644 index 0000000..75f3c1d --- /dev/null +++ b/tests/delta.tests.js @@ -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 world!' + * 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('!')) +} diff --git a/tests/doc.tests.js b/tests/doc.tests.js new file mode 100644 index 0000000..88d1ef6 --- /dev/null +++ b/tests/doc.tests.js @@ -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|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|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) +} diff --git a/tests/encoding.tests.js b/tests/encoding.tests.js new file mode 100644 index 0000000..63fc000 --- /dev/null +++ b/tests/encoding.tests.js @@ -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>} + */ + 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) +} diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..ff6adcf --- /dev/null +++ b/tests/index.js @@ -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() diff --git a/tests/relativePositions.tests.js b/tests/relativePositions.tests.js new file mode 100644 index 0000000..ad1b870 --- /dev/null +++ b/tests/relativePositions.tests.js @@ -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} */ (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} */ (yroot.get(0)) + mid.insert(0, ['q', new Y.Node()]) + const leaf = /** @type {Y.Node} */ (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)) + }) +} diff --git a/tests/schema.tests.js b/tests/schema.tests.js new file mode 100644 index 0000000..0b5d192 --- /dev/null +++ b/tests/schema.tests.js @@ -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} + */ +const attr = val => Y.createContentAttribute('u', val) + +/** + * An IdMap holding one attribute value per range, all on client 1. + * + * @param {Array} vals + * @return {IdMap} + */ +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]>} + */ +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, 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) + }) +} diff --git a/tests/snapshot.tests.js b/tests/snapshot.tests.js new file mode 100644 index 0000000..00f9b3b --- /dev/null +++ b/tests/snapshot.tests.js @@ -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} + */ + 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)) +} diff --git a/tests/testHelper.js b/tests/testHelper.js new file mode 100644 index 0000000..5c4de2a --- /dev/null +++ b/tests/testHelper.js @@ -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>} + */ + 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>} + */ + 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} */ ([])).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} + */ + this.allConns = new Set() + /** + * @type {Set} + */ + 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} + */ + 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} [initTestObject] + * @return {{testObjects:Array,testConnector:TestConnector,users:Array,array0:Y.Node,array1:Y.Node,array2:Y.Node,map0:Y.Node,map1:Y.Node,map2:Y.Node,map3:Y.Node,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} + */ + 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} */ (items2)[i] + t.assert(di1.clock === di2.clock && di1.len === di2.len) + } + } + return true +} + +/** + * only use for testing + * + * @template T + * @param {Array>} attrs + * @param {Y.ContentAttribute} attr + * + */ +const _idmapAttrsHas = (attrs, attr) => { + const hash = attr.hash() + return attrs.find(a => a.hash() === hash) +} + +/** + * only use for testing + * + * @template T + * @param {Array>} a + * @param {Array>} 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} 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} idmap1 + * @param {Y.IdMap} 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>} */ (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} attrChoices (max clock - exclusive - by each client) + * @return {Y.IdMap} + */ +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} 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} */ (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} mods + * @param {number} iterations + * @param {InitTestObjectCallback} [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} ApplyRandomTestsResult + */ diff --git a/tests/undo-redo.tests.js b/tests/undo-redo.tests.js new file mode 100644 index 0000000..5fdd134 --- /dev/null +++ b/tests/undo-redo.tests.js @@ -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(), '') +} + +/** + * 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(), '') + 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(), '') +} + +/** + * 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') +} diff --git a/tests/updates.tests.js b/tests/updates.tests.js new file mode 100644 index 0000000..7c0dc0d --- /dev/null +++ b/tests/updates.tests.js @@ -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} Enc.mergeUpdates + * @property {function(Y.Doc):Uint8Array} Enc.encodeStateAsUpdate + * @property {function(Y.Doc, Uint8Array):void} Enc.applyUpdate + * @property {function(Uint8Array):void} Enc.logUpdate + * @property {function(Uint8Array):{deletes:Y.IdSet,inserts:Y.IdSet}} Enc.readUpdateToContentIds + * @property {function(Y.Doc):Uint8Array} Enc.encodeStateVector + * @property {function(Uint8Array):Uint8Array} Enc.encodeStateVectorFromUpdate + * @property {'update'|'updateV2'} Enc.updateEventName + * @property {string} Enc.description + * @property {function(Uint8Array, Uint8Array):Uint8Array} Enc.diffUpdate + */ + +/** + * @type {Enc} + */ +const encV1 = { + mergeUpdates: Y.mergeUpdates, + encodeStateAsUpdate: Y.encodeStateAsUpdate, + applyUpdate: Y.applyUpdate, + logUpdate: Y.logUpdate, + 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} 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>} 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} */ (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>} */ ([]) + 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>} */ ([]) + 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>} */ ([]) + 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} + */ + 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) +} diff --git a/tests/y-array.tests.js b/tests/y-array.tests.js new file mode 100644 index 0000000..5f0f687 --- /dev/null +++ b/tests/y-array.tests.js @@ -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?} + */ + 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} + */ + 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|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} + */ + 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>} + */ + 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} + */ +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) +} diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js new file mode 100644 index 0000000..c84e678 --- /dev/null +++ b/tests/y-map.tests.js @@ -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} + */ + const vals = Array.from(ymap.attrValues()) + /** + * @type {Array<[string,number]>} + */ + const entries = Array.from(ymap.attrEntries()) + /** + * @type {Array} + */ + 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} is + * @param {Object} 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} + */ + 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} + */ + 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?} + */ + 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} + */ + 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} + */ + 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} + */ +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) +} diff --git a/tests/y-text.tests.js b/tests/y-text.tests.js new file mode 100644 index 0000000..2169549 --- /dev/null +++ b/tests/y-text.tests.js @@ -0,0 +1,2385 @@ +import * as Y from './testHelper.js' +import * as t from 'lib0/testing' +import * as prng from 'lib0/prng' +import * as math from 'lib0/math' +import * as delta from 'lib0/delta' +import { createIdMapFromIdSet } from '../src/utils/ids.js' +import { AttributionsRenderer, createSnapshotRenderer } from '../src/utils/Renderer.js' + +const { init, compare } = Y + +/** + * https://github.com/yjs/yjs/issues/474 + * @todo Remove debug: 127.0.0.1:8080/test.html?filter=\[88/ + * @param {t.TestCase} _tc + */ +export const testDeltaBug = _tc => { + const initialDelta = delta.create() + .insert('\n', { + 'block-id': 'block-28eea923-9cbb-4b6f-a950-cf7fd82bc087' + }) + .insert('\n\n\n', { + 'table-col': { + width: '150' + } + }) + .insert('\n', { + 'block-id': 'block-9144be72-e528-4f91-b0b2-82d20408e9ea', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-apba4k' + }, + row: 'row-6kv2ls', + cell: 'cell-apba4k', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-639adacb-1516-43ed-b272-937c55669a1c', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-a8qf0r' + }, + row: 'row-6kv2ls', + cell: 'cell-a8qf0r', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-6302ca4a-73a3-4c25-8c1e-b542f048f1c6', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-oi9ikb' + }, + row: 'row-6kv2ls', + cell: 'cell-oi9ikb', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-ceeddd05-330e-4f86-8017-4a3a060c4627', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-dt6ks2' + }, + row: 'row-d1sv2g', + cell: 'cell-dt6ks2', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-37b19322-cb57-4e6f-8fad-0d1401cae53f', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-qah2ay' + }, + row: 'row-d1sv2g', + cell: 'cell-qah2ay', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-468a69b5-9332-450b-9107-381d593de249', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-fpcz5a' + }, + row: 'row-d1sv2g', + cell: 'cell-fpcz5a', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-26b1d252-9b2e-4808-9b29-04e76696aa3c', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-zrhylp' + }, + row: 'row-pflz90', + cell: 'cell-zrhylp', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-6af97ba7-8cf9-497a-9365-7075b938837b', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-s1q9nt' + }, + row: 'row-pflz90', + cell: 'cell-s1q9nt', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-107e273e-86bc-44fd-b0d7-41ab55aca484', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-20b0j9' + }, + row: 'row-pflz90', + cell: 'cell-20b0j9', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-38161f9c-6f6d-44c5-b086-54cc6490f1e3' + }) + .insert('Content after table') + .insert('\n', { + 'block-id': 'block-15630542-ef45-412d-9415-88f0052238ce' + }) + const ydoc1 = new Y.Doc() + const ytext = ydoc1.get() + ytext.applyDelta(initialDelta) + const addingDash = delta.create().retain(12).insert('-') + ytext.applyDelta(addingDash) + const addingSpace = delta.create().retain(13).insert(' ') + ytext.applyDelta(addingSpace) + const addingList = delta.create().retain(12).delete(2).retain(1, { + // Clear table line attribute + 'table-cell-line': null, + // Add list attribute in place of table-cell-line + list: { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-20b0j9', + list: 'bullet' + } + }) + ytext.applyDelta(addingList) + const result = ytext.toDelta() + const expectedResult = delta.create() + .insert('\n', { 'block-id': 'block-28eea923-9cbb-4b6f-a950-cf7fd82bc087' }) + .insert('\n\n\n', { 'table-col': { width: '150' } }) + .insert('\n', { + 'block-id': 'block-9144be72-e528-4f91-b0b2-82d20408e9ea', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-apba4k' + }, + row: 'row-6kv2ls', + cell: 'cell-apba4k', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-639adacb-1516-43ed-b272-937c55669a1c', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-a8qf0r' + }, + row: 'row-6kv2ls', + cell: 'cell-a8qf0r', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-6302ca4a-73a3-4c25-8c1e-b542f048f1c6', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-6kv2ls', + cell: 'cell-oi9ikb' + }, + row: 'row-6kv2ls', + cell: 'cell-oi9ikb', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-ceeddd05-330e-4f86-8017-4a3a060c4627', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-dt6ks2' + }, + row: 'row-d1sv2g', + cell: 'cell-dt6ks2', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-37b19322-cb57-4e6f-8fad-0d1401cae53f', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-qah2ay' + }, + row: 'row-d1sv2g', + cell: 'cell-qah2ay', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-468a69b5-9332-450b-9107-381d593de249', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-d1sv2g', + cell: 'cell-fpcz5a' + }, + row: 'row-d1sv2g', + cell: 'cell-fpcz5a', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-26b1d252-9b2e-4808-9b29-04e76696aa3c', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-zrhylp' + }, + row: 'row-pflz90', + cell: 'cell-zrhylp', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-6af97ba7-8cf9-497a-9365-7075b938837b', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-s1q9nt' + }, + row: 'row-pflz90', + cell: 'cell-s1q9nt', + rowspan: '1', + colspan: '1' + }) + // This attributes has only list and no table-cell-line + .insert('\n', { + list: { + rowspan: '1', + colspan: '1', + row: 'row-pflz90', + cell: 'cell-20b0j9', + list: 'bullet' + }, + 'block-id': 'block-107e273e-86bc-44fd-b0d7-41ab55aca484', + row: 'row-pflz90', + cell: 'cell-20b0j9', + rowspan: '1', + colspan: '1' + }) + // No table-cell-line below here + .insert('\n', { + 'block-id': 'block-38161f9c-6f6d-44c5-b086-54cc6490f1e3' + }) + .insert('Content after table') + .insert('\n', { + 'block-id': 'block-15630542-ef45-412d-9415-88f0052238ce' + }) + t.compare(result, expectedResult) +} + +/** + * https://github.com/yjs/yjs/issues/503 + * @param {t.TestCase} _tc + */ +export const testDeltaBug2 = _tc => { + const initialContent = delta.create(delta.$deltaAny) + .insert("Thomas' section") + .insert('\n', { 'block-id': 'block-61ae80ac-a469-4eae-bac9-3b6a2c380118' }) + .insert('\n', { 'block-id': 'block-d265d93f-1cc7-40ee-bb58-8270fca2619f' }) + .insert('123') + .insert('\n', { + 'block-id': 'block-592a7bee-76a3-4e28-9c25-7a84344f8813', + list: { list: 'toggled', 'toggle-id': 'list-66xfft' } + }) + .insert('456') + .insert('\n', { + indent: 1, + 'block-id': 'block-3ee2bd70-b97f-45b2-9115-f1e8910235b1', + list: { list: 'toggled', 'toggle-id': 'list-6vh0t0' } + }) + .insert('789') + .insert('\n', { + indent: 1, + 'block-id': 'block-78150cf3-9bb5-4dea-a6f5-0ce1d2a98b9c', + list: { list: 'toggled', 'toggle-id': 'list-7jr0l2' } + }) + .insert('901') + .insert('\n', { + indent: 1, + 'block-id': 'block-13c6416f-f522-41d5-9fd4-ce4eb1cde5ba', + list: { list: 'toggled', 'toggle-id': 'list-7uk8qu' } + }) + .insert([{ + slash_command: { + id: 'doc_94zq-2436', + sessionId: 'nkwc70p2j', + replace: '/' + } + }]) + .insert('\n', { 'block-id': 'block-8a1d2bb6-23c2-4bcf-af3c-3919ffea1697' }) + .insert('\n\n\n', { 'table-col': { width: '150' } }) + .insert('\n', { + 'block-id': 'block-84ec3ea4-da6a-4e03-b430-0e5f432936a9', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-blmd4s', + cell: 'cell-m0u5za' + }, + row: 'row-blmd4s', + cell: 'cell-m0u5za', + rowspan: '1', + colspan: '1' + } + ) + .insert('\n', { + 'block-id': 'block-83144ca8-aace-401e-8aa5-c05928a8ccf0', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-blmd4s', + cell: 'cell-1v8s8t' + }, + row: 'row-blmd4s', + cell: 'cell-1v8s8t', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-9a493387-d27f-4b58-b2f7-731dfafda32a', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-blmd4s', + cell: 'cell-126947' + }, + row: 'row-blmd4s', + cell: 'cell-126947', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-3484f86e-ae42-440f-8de6-857f0d8011ea', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-hmmljo', + cell: 'cell-wvutl9' + }, + row: 'row-hmmljo', + cell: 'cell-wvutl9', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-d4e0b741-9dea-47a5-85e1-4ded0efbc89d', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-hmmljo', + cell: 'cell-nkablr' + }, + row: 'row-hmmljo', + cell: 'cell-nkablr', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-352f0d5a-d1b9-422f-b136-4bacefd00b1a', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-hmmljo', + cell: 'cell-n8xtd0' + }, + row: 'row-hmmljo', + cell: 'cell-n8xtd0', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-95823e57-f29c-44cf-a69d-2b4494b7144b', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ev4xwq', + cell: 'cell-ua9bvu' + }, + row: 'row-ev4xwq', + cell: 'cell-ua9bvu', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-cde5c027-15d3-4780-9e76-1e1a9d97a8e8', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ev4xwq', + cell: 'cell-7bwuvk' + }, + row: 'row-ev4xwq', + cell: 'cell-7bwuvk', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-11a23ed4-b04d-4e45-8065-8120889cd4a4', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ev4xwq', + cell: 'cell-aouka5' + }, + row: 'row-ev4xwq', + cell: 'cell-aouka5', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { 'block-id': 'block-15b4483c-da98-4ded-91d3-c3d6ebc82582' }) + .insert([{ divider: true }]) + .insert('\n', { 'block-id': 'block-68552c8e-b57b-4f4a-9f36-6cc1ef6b3461' }) + .insert('jklasjdf') + .insert('\n', { + 'block-id': 'block-c8b2df7d-8ec5-4dd4-81f1-8d8efc40b1b4', + list: { list: 'toggled', 'toggle-id': 'list-9ss39s' } + }) + .insert('asdf') + .insert('\n', { + 'block-id': 'block-4f252ceb-14da-49ae-8cbd-69a701d18e2a', + list: { list: 'toggled', 'toggle-id': 'list-uvo013' } + }) + .insert('adg') + .insert('\n', { + 'block-id': 'block-ccb9b72e-b94d-45a0-aae4-9b0a1961c533', + list: { list: 'toggled', 'toggle-id': 'list-k53iwe' } + }) + .insert('asdfasdfasdf') + .insert('\n', { + 'block-id': 'block-ccb9b72e-b94d-45a0-aae4-9b0a1961c533', + list: { list: 'none' }, + indent: 1 + }) + .insert('asdf') + .insert('\n', [{ + 'block-id': 'block-f406f76d-f338-4261-abe7-5c9131f7f1ad', + list: { list: 'toggled', 'toggle-id': 'list-en86ur' } + }]) + .insert('\n', { 'block-id': 'block-be18141c-9b6b-434e-8fd0-2c214437d560' }) + .insert('\n', { 'block-id': 'block-36922db3-4af5-48a1-9ea4-0788b3b5d7cf' }) + .insert([{ table_content: true }]) + .insert(' ') + .insert([{ + slash_command: { + id: 'doc_94zq-2436', + sessionId: 'hiyrt6fny', + replace: '/' + } + }]) + .insert('\n', { 'block-id': 'block-9d6566a1-be55-4e20-999a-b990bc15e143' }) + .insert('\n', { + 'block-id': 'block-4b545085-114d-4d07-844c-789710ec3aab', + layout: + '12d887e1-d1a2-4814-a1a3-0c904e950b46_1185cd29-ef1b-45d5-8fda-51a70b704e64', + 'layout-width': '0.25' + }) + .insert('\n', { + 'block-id': 'block-4d3f2321-33d1-470e-9b7c-d5a683570148', + layout: + '12d887e1-d1a2-4814-a1a3-0c904e950b46_75523ea3-c67f-4f5f-a85f-ac7c8fc0a992', + 'layout-width': '0.5' + }) + .insert('\n', { + 'block-id': 'block-4c7ae1e6-758e-470f-8d7c-ae0325e4ee8a', + layout: + '12d887e1-d1a2-4814-a1a3-0c904e950b46_54c740ef-fd7b-48c6-85aa-c14e1bfc9297', + 'layout-width': '0.25' + }) + .insert('\n', { 'block-id': 'block-2d6ff0f4-ff00-42b7-a8e2-b816463d8fb5' }) + .insert([{ divider: true }]) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n', { 'table-col': { width: '154' } }) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n', { + 'block-id': 'block-38545d56-224b-464c-b779-51fcec24dbbf', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-q0qfck', + cell: 'cell-hmapv4' + }, + row: 'row-q0qfck', + cell: 'cell-hmapv4', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-d413a094-5f52-4fd4-a4aa-00774f6fdb44', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-q0qfck', + cell: 'cell-c0czb2' + }, + row: 'row-q0qfck', + cell: 'cell-c0czb2', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-ff855cbc-8871-4e0a-9ba7-de0c1c2aa585', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-q0qfck', + cell: 'cell-hcpqmm' + }, + row: 'row-q0qfck', + cell: 'cell-hcpqmm', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-4841e6ee-fef8-4473-bf04-f5ba62db17f0', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-etopyl', + cell: 'cell-0io73v' + }, + row: 'row-etopyl', + cell: 'cell-0io73v', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-adeec631-d4fe-4f38-9d5e-e67ba068bd24', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-etopyl', + cell: 'cell-gt2waa' + }, + row: 'row-etopyl', + cell: 'cell-gt2waa', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-d38a7308-c858-4ce0-b1f3-0f9092384961', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-etopyl', + cell: 'cell-os9ksy' + }, + row: 'row-etopyl', + cell: 'cell-os9ksy', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-a9df6568-1838-40d1-9d16-3c073b6ce169', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-hbx9ri' + }, + row: 'row-0jwjg3', + cell: 'cell-hbx9ri', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-e26a0cf2-fe62-44a5-a4ca-8678a56d62f1', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-yg5m2w' + }, + row: 'row-0jwjg3', + cell: 'cell-yg5m2w', + rowspan: '1', + colspan: '1' + }) + .insert('a') + .insert('\n', { + 'block-id': 'block-bfbc5ac2-7417-44b9-9aa5-8e36e4095627', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('b') + .insert('\n', { + 'block-id': 'block-f011c089-6389-47c0-8396-7477a29aa56f', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('c') + .insert('\n', { + 'block-id': 'block-4497788d-1e02-4fd5-a80a-48b61a6185cb', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('d') + .insert('\n', { + 'block-id': 'block-5d73a2c7-f98b-47c7-a3f5-0d8527962b02', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('e') + .insert('\n', { + 'block-id': 'block-bfda76ee-ffdd-45db-a22e-a6707e11cf68', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('d') + .insert('\n', { + 'block-id': 'block-35242e64-a69d-4cdb-bd85-2a93766bfab4', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('f') + .insert('\n', { + 'block-id': 'block-8baa22c8-491b-4f1b-9502-44179d5ae744', + list: { + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2', + list: 'ordered' + }, + rowspan: '1', + colspan: '1', + row: 'row-0jwjg3', + cell: 'cell-1azhl2' + }) + .insert('\n', { 'block-id': 'block-7fa64af0-6974-4205-8cee-529f8bd46852' }) + .insert([{ divider: true }]) + .insert("Brandon's Section") + .insert('\n', { + header: 2, + 'block-id': 'block-cf49462c-2370-48ff-969d-576cb32c39a1' + }) + .insert('\n', { 'block-id': 'block-30ef8361-0dd6-4eee-b4eb-c9012d0e9070' }) + .insert([{ + slash_command: { + id: 'doc_94zq-2436', + sessionId: 'x9x08o916', + replace: '/' + } + }]) + .insert('\n', { 'block-id': 'block-166ed856-cf8c-486a-9365-f499b21d91b3' }) + .insert([{ divider: true }]) + .insert('\n', { + row: 'row-kssn15', + rowspan: '1', + colspan: '1', + 'block-id': 'block-e8079594-4559-4259-98bb-da5280e2a692', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-kssn15', + cell: 'cell-qxbksf' + }, + cell: 'cell-qxbksf' + }) + .insert('\n', { + 'block-id': 'block-70132663-14cc-4701-b5c5-eb99e875e2bd', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-kssn15', + cell: 'cell-lsohbx' + }, + cell: 'cell-lsohbx', + row: 'row-kssn15', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-47a3899c-e3c5-4a7a-a8c4-46e0ae73a4fa', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-kssn15', + cell: 'cell-hner9k' + }, + cell: 'cell-hner9k', + row: 'row-kssn15', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-0f9e650a-7841-412e-b4f2-5571b6d352c2', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-juxwc0', + cell: 'cell-ei4yqp' + }, + cell: 'cell-ei4yqp', + row: 'row-juxwc0', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-53a158a9-8c82-4c82-9d4e-f5298257ca43', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-juxwc0', + cell: 'cell-25pf5x' + }, + cell: 'cell-25pf5x', + row: 'row-juxwc0', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-da8ba35e-ce6e-4518-8605-c51d781eb07a', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-juxwc0', + cell: 'cell-m8reor' + }, + cell: 'cell-m8reor', + row: 'row-juxwc0', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-2dce37c7-2978-4127-bed0-9549781babcb', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ot4wy5', + cell: 'cell-dinh0i' + }, + cell: 'cell-dinh0i', + row: 'row-ot4wy5', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-7b593f8c-4ea3-44b4-8ad9-4a0abffe759b', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ot4wy5', + cell: 'cell-d115b2' + }, + cell: 'cell-d115b2', + row: 'row-ot4wy5', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-272c28e6-2bde-4477-9d99-ce35b3045895', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-ot4wy5', + cell: 'cell-fuapvo' + }, + cell: 'cell-fuapvo', + row: 'row-ot4wy5', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { 'block-id': 'block-fbf23cab-1ce9-4ede-9953-f2f8250004cf' }) + .insert('\n', { 'block-id': 'block-c3fbb8c9-495c-40b0-b0dd-f6e33dd64b1b' }) + .insert('\n', { 'block-id': 'block-3417ad09-92a3-4a43-b5db-6dbcb0f16db4' }) + .insert('\n', { 'block-id': 'block-b9eacdce-4ba3-4e66-8b69-3eace5656057' }) + .insert('Dan Gornstein') + .insert('\n', { + 'block-id': 'block-d7c6ae0d-a17c-433e-85fd-5efc52b587fb', + header: 1 + }) + .insert('\n', { 'block-id': 'block-814521bd-0e14-4fbf-b332-799c6452a624' }) + .insert('aaa') + .insert('\n', { + 'block-id': 'block-6aaf4dcf-dc21-45c6-b723-afb25fe0f498', + list: { list: 'toggled', 'toggle-id': 'list-idl93b' } + }) + .insert('bb') + .insert('\n', { + indent: 1, + 'block-id': 'block-3dd75392-fa50-4bfb-ba6b-3b7d6bd3f1a1', + list: { list: 'toggled', 'toggle-id': 'list-mrq7j2' } + }) + .insert('ccc') + .insert('\n', { + 'block-id': 'block-2528578b-ecda-4f74-9fd7-8741d72dc8b3', + indent: 2, + list: { list: 'toggled', 'toggle-id': 'list-liu7dl' } + }) + .insert('\n', { 'block-id': 'block-18bf68c3-9ef3-4874-929c-9b6bb1a00325' }) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n', { + 'block-id': 'block-d44e74b4-b37f-48e0-b319-6327a6295a57', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie' + }, + row: 'row-si1nah', + cell: 'cell-cpybie', + rowspan: '1', + colspan: '1' + }) + .insert('aaa') + .insert('\n', { + 'block-id': 'block-3e545ee9-0c9a-42d7-a4d0-833edb8087f3', + list: { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie', + list: 'toggled', + 'toggle-id': 'list-kjl2ik' + }, + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie' + }) + .insert('bb') + .insert('\n', { + indent: 1, + 'block-id': 'block-5f1225ad-370f-46ab-8f1e-18b277b5095f', + list: { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie', + list: 'toggled', + 'toggle-id': 'list-eei1x5' + }, + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie' + }) + .insert('ccc') + .insert('\n', { + indent: 2, + 'block-id': 'block-a77fdc11-ad24-431b-9ca2-09e32db94ac2', + list: { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie', + list: 'toggled', + 'toggle-id': 'list-30us3c' + }, + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie' + }) + .insert('\n', { + 'block-id': 'block-d44e74b4-b37f-48e0-b319-6327a6295a57', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-cpybie' + }, + row: 'row-si1nah', + cell: 'cell-cpybie', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-2c274c8a-757d-4892-8db8-1a7999f7ab51', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-al1z64' + }, + row: 'row-si1nah', + cell: 'cell-al1z64', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-85931afe-1879-471c-bb4b-89e7bd517fe9', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-si1nah', + cell: 'cell-q186pb' + }, + row: 'row-si1nah', + cell: 'cell-q186pb', + rowspan: '1', + colspan: '1' + }) + .insert('asdfasdfasdf') + .insert('\n', { + 'block-id': 'block-6e0522e8-c1eb-4c07-98df-2b07c533a139', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-7x2d1o', + cell: 'cell-6eid2t' + }, + row: 'row-7x2d1o', + cell: 'cell-6eid2t', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-4b3d0bd0-9175-45e9-955c-e8164f4b5376', + row: 'row-7x2d1o', + cell: 'cell-m1alad', + rowspan: '1', + colspan: '1', + list: { + rowspan: '1', + colspan: '1', + row: 'row-7x2d1o', + cell: 'cell-m1alad', + list: 'ordered' + } + }) + .insert('asdfasdfasdf') + .insert('\n', { + 'block-id': 'block-08610089-cb05-4366-bb1e-a0787d5b11bf', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-7x2d1o', + cell: 'cell-dm1l2p' + }, + row: 'row-7x2d1o', + cell: 'cell-dm1l2p', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-c22b5125-8df3-432f-bd55-5ff456e41b4e', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-o0ujua', + cell: 'cell-82g0ca' + }, + row: 'row-o0ujua', + cell: 'cell-82g0ca', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-7c6320e4-acaf-4ab4-8355-c9b00408c9c1', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-o0ujua', + cell: 'cell-wv6ozp' + }, + row: 'row-o0ujua', + cell: 'cell-wv6ozp', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-d1bb7bed-e69e-4807-8d20-2d28fef8d08f', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-o0ujua', + cell: 'cell-ldt53x' + }, + row: 'row-o0ujua', + cell: 'cell-ldt53x', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { 'block-id': 'block-28f28cb8-51a2-4156-acf9-2380e1349745' }) + .insert([{ divider: true }]) + .insert('\n', { 'block-id': 'block-a1193252-c0c8-47fe-b9f6-32c8b01a1619' }) + .insert('\n', { 'table-col': { width: '150' } }) + .insert('\n\n', { 'table-col': { width: '150' } }) + .insert('/This is a test.') + .insert('\n', { + 'block-id': 'block-14188df0-a63f-4317-9a6d-91b96a7ac9fe', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5ixdvv', + cell: 'cell-9tgyed' + }, + row: 'row-5ixdvv', + cell: 'cell-9tgyed', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-7e5ba2af-9903-457d-adf4-2a79be81d823', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5ixdvv', + cell: 'cell-xc56e9' + }, + row: 'row-5ixdvv', + cell: 'cell-xc56e9', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-eb6cad93-caf7-4848-8adf-415255139268', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5ixdvv', + cell: 'cell-xrze3u' + }, + row: 'row-5ixdvv', + cell: 'cell-xrze3u', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-5bb547a2-6f71-4624-80c7-d0e1318c81a2', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-xbzv98', + cell: 'cell-lie0ng' + }, + row: 'row-xbzv98', + cell: 'cell-lie0ng', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-b506de0d-efb6-4bd7-ba8e-2186cc57903e', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-xbzv98', + cell: 'cell-s9sow1' + }, + row: 'row-xbzv98', + cell: 'cell-s9sow1', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-42d2ad20-5521-40e3-a88d-fe6906176e61', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-xbzv98', + cell: 'cell-nodtcj' + }, + row: 'row-xbzv98', + cell: 'cell-nodtcj', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-7d3e4216-3f68-4dd6-bc77-4a9fad4ba008', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5bqfil', + cell: 'cell-c8c0f3' + }, + row: 'row-5bqfil', + cell: 'cell-c8c0f3', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-6671f221-551e-47fb-9b7d-9043b6b12cdc', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5bqfil', + cell: 'cell-jvxxif' + }, + row: 'row-5bqfil', + cell: 'cell-jvxxif', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { + 'block-id': 'block-51e3161b-0437-4fe3-ac4f-129a93a93fc3', + 'table-cell-line': { + rowspan: '1', + colspan: '1', + row: 'row-5bqfil', + cell: 'cell-rmjpze' + }, + row: 'row-5bqfil', + cell: 'cell-rmjpze', + rowspan: '1', + colspan: '1' + }) + .insert('\n', { 'block-id': 'block-21099df0-afb2-4cd3-834d-bb37800eb06a' }) + const ydoc = new Y.Doc() + const ytext = ydoc.get('id') + ytext.applyDelta(initialContent) + const changeEvent = delta.create().retain(90).delete(4).retain(1, { + layout: null, + 'layout-width': null, + 'block-id': 'block-9d6566a1-be55-4e20-999a-b990bc15e143' + }) + ytext.applyDelta(changeEvent) + const d = ytext.toDelta() + t.compare(d.toJSON().children?.[40], { + type: 'insert', + insert: '\n', + format: { + 'block-id': 'block-9d6566a1-be55-4e20-999a-b990bc15e143' + } + }) +} + +/** + * In this test we are mainly interested in the cleanup behavior and whether the resulting delta makes sense. + * It is fine if the resulting delta is not minimal. But applying the delta to a rich-text editor should result in a + * synced document. + * + * @param {t.TestCase} tc + */ +export const testDeltaAfterConcurrentFormatting = tc => { + const { text0, text1, testConnector } = init(tc, { users: 2 }) + text0.insert(0, 'abcde') + testConnector.flushAllMessages() + text0.format(0, 3, { bold: true }) + text1.format(2, 2, { bold: true }) + /** + * @type {any} + */ + const deltas = [] + text1.observe(event => { + if (event.delta.children.len > 0) { + deltas.push(event.delta) + } + }) + testConnector.flushAllMessages() + t.assert(deltas.length === 1) + t.compare(deltas[0], delta.create().retain(2, { bold: true }).retain(1).retain(1, { bold: null }).done()) +} + +/** + * @param {t.TestCase} tc + */ +export const testBasicInsertAndDelete = tc => { + const { users, text0 } = init(tc, { users: 2 }) + let eventDelta + + text0.observe(event => { + eventDelta = event.delta + }) + + text0.delete(0, 0) + t.assert(true, 'Does not throw when deleting zero elements with position 0') + + text0.insert(0, 'abc') + t.assert(text0.toString() === 'abc', 'Basic insert works') + t.compare(eventDelta, delta.create().insert('abc')) + + text0.delete(0, 1) + t.assert(text0.toString() === 'bc', 'Basic delete works (position 0)') + t.compare(eventDelta, delta.create().delete(1)) + + text0.delete(1, 1) + t.assert(text0.toString() === 'b', 'Basic delete works (position 1)') + t.compare(eventDelta, delta.create().retain(1).delete(1)) + + users[0].transact(() => { + text0.insert(0, '1') + text0.delete(0, 1) + }) + t.compare(eventDelta, delta.create()) + + compare(users) +} + +/** + * @param {t.TestCase} tc + */ +export const testBasicFormat = tc => { + const { users, text0 } = init(tc, { users: 2 }) + let eventDelta + text0.observe(event => { + eventDelta = event.delta + }) + text0.insert(0, 'abc', { bold: true }) + t.assert(text0.toString() === 'abc', 'Basic insert with attributes works') + t.compare(text0.toDelta(), delta.create().insert('abc', { bold: true }).done()) + t.compare(eventDelta, delta.create().insert('abc', { bold: true })) + text0.delete(0, 1) + t.assert(text0.toString() === 'bc', 'Basic delete on formatted works (position 0)') + t.compare(text0.toDelta(), delta.create().insert('bc', { bold: true }).done()) + t.compare(eventDelta, delta.create().delete(1)) + text0.delete(1, 1) + t.assert(text0.toString() === 'b', 'Basic delete works (position 1)') + t.compare(text0.toDelta(), delta.create().insert('b', { bold: true }).done()) + t.compare(eventDelta, delta.create().retain(1).delete(1)) + text0.insert(0, 'z', { bold: true }) + t.assert(text0.toString() === 'zb') + t.compare(text0.toDelta(), delta.create().insert('zb', { bold: true }).done()) + t.compare(eventDelta, delta.create().insert('z', { bold: true })) + // @ts-ignore + t.assert(text0._start.right.right.right.content.str === 'b', 'Does not insert duplicate attribute marker') + text0.insert(0, 'y') + t.assert(text0.toString() === 'yzb') + t.compare(text0.toDelta(), delta.create().insert('y').insert('zb', { bold: true }).done()) + t.compare(eventDelta, delta.create().insert('y')) + text0.format(0, 2, { bold: null }) + t.assert(text0.toString() === 'yzb') + t.compare(text0.toDelta(), delta.create().insert('yz').insert('b', { bold: true }).done()) + t.compare(eventDelta, delta.create().retain(1).retain(1, { bold: null })) + compare(users) +} + +/** + * @param {t.TestCase} tc + */ +export const testFalsyFormats = tc => { + const { users, text0 } = init(tc, { users: 2 }) + let delta = text0.toDelta().toJSON().children + text0.observe(event => { + delta = event.delta.toJSON().children + }) + text0.insert(0, 'abcde', { falsy: false }) + t.compare(text0.toDelta().toJSON().children, [{ type: 'insert', insert: 'abcde', format: { falsy: false } }]) + t.compare(delta, [{ type: 'insert', insert: 'abcde', format: { falsy: false } }]) + text0.format(1, 3, { falsy: true }) + t.compare(text0.toDelta().toJSON().children, [{ type: 'insert', insert: 'a', format: { falsy: false } }, { type: 'insert', insert: 'bcd', format: { falsy: true } }, { type: 'insert', insert: 'e', format: { falsy: false } }]) + t.compare(delta, [{ type: 'retain', retain: 1 }, { type: 'retain', retain: 3, format: { falsy: true } }]) + text0.format(2, 1, { falsy: false }) + t.compare(text0.toDelta().toJSON().children, [{ type: 'insert', insert: 'a', format: { falsy: false } }, { type: 'insert', insert: 'b', format: { falsy: true } }, { type: 'insert', insert: 'c', format: { falsy: false } }, { type: 'insert', insert: 'd', format: { falsy: true } }, { type: 'insert', insert: 'e', format: { falsy: false } }]) + t.compare(delta, [{ type: 'retain', retain: 2 }, { type: 'retain', retain: 1, format: { falsy: false } }]) + compare(users) +} + +/** + * @param {t.TestCase} _tc + */ +export const testMultilineFormat = _tc => { + const ydoc = new Y.Doc() + const testText = ydoc.get('test') + testText.insert(0, 'Test\nMulti-line\nFormatting') + const tt = delta.create() + .retain(4, { bold: true }) + .retain(1) // newline character + .retain(10, { bold: true }) + .retain(1) // newline character + .retain(10, { bold: true }) + testText.applyDelta(tt) + t.compare( + testText.toDelta(), + delta.create() + .insert('Test', { bold: true }) + .insert('\n') + .insert('Multi-line', { bold: true }) + .insert('\n') + .insert('Formatting', { bold: true }) + .done() + ) +} + +/** + * @param {t.TestCase} _tc + */ +export const testNotMergeEmptyLinesFormat = _tc => { + const ydoc = new Y.Doc() + const testText = ydoc.get('test') + testText.applyDelta(delta.create() + .insert('Text') + .insert('\n', { title: true }) + .insert('\nText') + .insert('\n', { title: true }).done() + ) + t.compare( + testText.toDelta(), + delta.create() + .insert('Text') + .insert('\n', { title: true }) + .insert('\nText') + .insert('\n', { title: true }) + .done() + ) +} + +/** + * @param {t.TestCase} _tc + */ +export const testPreserveAttributesThroughDelete = _tc => { + const ydoc = new Y.Doc() + const testText = ydoc.get('test') + testText.applyDelta(delta.create() + .insert('Text') + .insert('\n', { title: true }) + .insert('\n') + .done() + ) + testText.applyDelta(delta.create() + .retain(4) + .delete(1) + .retain(1, { title: true }) + ) + t.compare(testText.toDelta(), + delta.create() + .insert('Text') + .insert('\n', { title: true }) + .done() + ) +} + +/** + * @param {t.TestCase} tc + */ +export const testGetDeltaWithEmbeds = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.applyDelta(delta.create().insert([{ linebreak: 's' }]).done()) + t.compare(text0.toDelta(), + delta.create() + .insert([{ linebreak: 's' }]) + .done() + ) +} + +/** + * @param {t.TestCase} tc + */ +export const testTypesAsEmbed = tc => { + const { text0, text1, testConnector } = init(tc, { users: 2 }) + text0.applyDelta(delta.create() + .insert([Y.Node.from(delta.create().setAttr('key', 'val'))]) + .done() + ) + t.compare(/** @type {any} */ (text0).toDeltaDeep().toJSON().children, [{ type: 'insert', insert: [{ type: 'delta', attrs: { key: { type: 'insert', value: 'val' } } }] }]) + let firedEvent = false + text1.observe(event => { + const d = event.deltaDeep + t.assert(d.children.len === 1) + t.compare(d.toJSON().children?.map(x => /** @type {any} */ (x).insert), [[{ type: 'delta', attrs: { key: { type: 'insert', value: 'val' } } }]]) + firedEvent = true + }) + testConnector.flushAllMessages() + const dd = text1.toDeltaDeep().toJSON().children + t.assert(dd?.length === 1) + t.compare(/** @type {any} */ (dd?.[0]).insert[0], { type: 'delta', attrs: { key: { type: 'insert', value: 'val' } } }) + t.assert(firedEvent, 'fired the event observer containing a Type-Embed') +} + +/** + * @param {t.TestCase} tc + */ +export const testSnapshot = tc => { + const { text0 } = init(tc, { users: 1 }) + const doc0 = /** @type {Y.Doc} */ (text0.doc) + doc0.gc = false + text0.applyDelta(delta.create().insert('abcd').done()) + const snapshot1 = Y.snapshot(doc0) + text0.applyDelta(delta.create() + .retain(1) + .insert('x') + .delete(1)) + const snapshot2 = Y.snapshot(doc0) + text0.applyDelta(delta.create() + .retain(2) + .delete(3) + .insert('x') + .delete(1) + ) + const state1 = text0.toDelta({ renderer: createSnapshotRenderer(snapshot1) }) + t.compare(state1, delta.create().insert('abcd').done()) + const state2 = text0.toDelta({ renderer: createSnapshotRenderer(snapshot2) }) + t.compare(state2, delta.create().insert('axcd').done()) + const state2Diff = text0.toDelta({ renderer: createSnapshotRenderer(snapshot1, snapshot2) }) + t.compare( + state2Diff, + delta.create() + .insert('a') + .insert('x', null, { insert: [] }) + .insert('b', null, { delete: [] }) + .insert('cd') + .done() + ) +} + +/** + * @param {t.TestCase} tc + */ +export const testSnapshotDeleteAfter = tc => { + const { text0 } = init(tc, { users: 1 }) + const doc0 = /** @type {Y.Doc} */ (text0.doc) + doc0.gc = false + text0.applyDelta(delta.create() + .insert('abcd') + .done() + ) + const snapshot1 = Y.snapshot(doc0) + text0.applyDelta(delta.create() + .retain(4) + .insert('e') + .done() + ) + const state1 = text0.toDelta({ renderer: createSnapshotRenderer(snapshot1) }) + t.compare(state1, delta.create().insert('abcd').done()) +} + +/** + * @param {t.TestCase} tc + */ +export const testDeltaCompare = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.insert(0, 'abc', { bold: true }) + t.compare(text0.toDelta(), delta.create().insert('abc', { bold: true }).done()) +} + +/** + * @param {t.TestCase} tc + */ +export const testToDeltaEmbedAttributes = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.insert(0, 'ab', { bold: true }) + text0.insert(1, [{ image: 'imageSrc.png' }], { width: 100 }) + const delta0 = text0.toDelta() + t.compare( + delta0, + delta.create() + .insert('a', { bold: true }) + .insert([{ image: 'imageSrc.png' }], { width: 100 }) + .insert('b', { bold: true }) + .done() + ) +} + +/** + * @param {t.TestCase} tc + */ +export const testToDeltaEmbedNoAttributes = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.insert(0, 'ab', { bold: true }) + text0.insert(1, [{ image: 'imageSrc.png' }]) + const delta0 = text0.toDelta() + t.compare( + delta0, + delta.create() + .insert('a', { bold: true }) + .insert([{ image: 'imageSrc.png' }]) + .insert('b', { bold: true }) + .done() + , 'toDelta does not set attributes key when no attributes are present' + ) +} + +/** + * @param {t.TestCase} tc + */ +export const testFormattingRemoved = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.insert(0, 'ab', { bold: true }) + text0.delete(0, 2) + // @ts-ignore + t.assert(Y.getNodeChildren(text0).length === 1) +} + +/** + * @param {t.TestCase} tc + */ +export const testFormattingRemovedInMidText = tc => { + const { text0 } = init(tc, { users: 1 }) + text0.insert(0, '1234') + text0.insert(2, 'ab', { bold: true }) + text0.delete(2, 2) + t.assert(Y.getNodeChildren(text0).length === 3) +} + +/** + * Reported in https://github.com/yjs/yjs/issues/344 + * + * @param {t.TestCase} tc + */ +export const testFormattingDeltaUnnecessaryAttributeChange = tc => { + const { text0, text1, testConnector } = init(tc, { users: 2 }) + text0.insert(0, '\n', { + PARAGRAPH_STYLES: 'normal', + LIST_STYLES: 'bullet' + }) + text0.insert(1, 'abc', { + PARAGRAPH_STYLES: 'normal' + }) + testConnector.flushAllMessages() + /** + * @type {Array>} + */ + const deltas = [] + text0.observe(event => { + deltas.push(event.delta) + }) + text1.observe(event => { + deltas.push(event.delta) + }) + text1.format(0, 1, { LIST_STYLES: 'number' }) + testConnector.flushAllMessages() + const filteredDeltas = deltas.filter(d => d.children.len > 0) + t.assert(filteredDeltas.length === 2) + t.compare(filteredDeltas[0], delta.create().retain(1, { LIST_STYLES: 'number' }).done()) + t.compare(filteredDeltas[0], filteredDeltas[1]) +} + +/** + * @param {t.TestCase} tc + */ +export const testInsertAndDeleteAtRandomPositions = tc => { + const N = 10000 + const { text0 } = init(tc, { users: 1 }) + const gen = tc.prng + + // create initial content + // let expectedResult = init + text0.insert(0, prng.word(gen, N / 2, N / 2)) + + // apply changes + for (let i = 0; i < N; i++) { + const pos = prng.uint32(gen, 0, text0.length) + if (prng.bool(gen)) { + const len = prng.uint32(gen, 1, 5) + const word = prng.word(gen, 0, len) + text0.insert(pos, word) + // expectedResult = expectedResult.slice(0, pos) + word + expectedResult.slice(pos) + } else { + const len = prng.uint32(gen, 0, math.min(3, text0.length - pos)) + text0.delete(pos, len) + // expectedResult = expectedResult.slice(0, pos) + expectedResult.slice(pos + len) + } + } + // t.compareStrings(text0.toString(), expectedResult) + t.describe('final length', '' + text0.length) +} + +/** + * @param {t.TestCase} tc + */ +export const testAppendChars = tc => { + const N = 10000 + const { text0 } = init(tc, { users: 1 }) + + // apply changes + for (let i = 0; i < N; i++) { + text0.insert(text0.length, 'a') + } + t.assert(text0.length === N) +} + +const largeDocumentSize = 100000 + +const id = Y.createID(0, 0) +const c = new Y.ContentString('a') + +/** + * @param {t.TestCase} _tc + */ +export const testBestCase = _tc => { + const N = largeDocumentSize + const items = new Array(N) + t.measureTime('time to create two million items in the best case', () => { + const parent = /** @type {any} */ ({}) + let prevItem = null + for (let i = 0; i < N; i++) { + /** + * @type {Y.Item} + */ + const n = new Y.Item(Y.createID(0, 0), null, null, null, null, null, null, c) + // items.push(n) + items[i] = n + n.right = prevItem + n.rightOrigin = prevItem ? id : null + n.content = c + n.parent = parent + prevItem = n + } + }) + const newArray = new Array(N) + t.measureTime('time to copy two million items to new Array', () => { + for (let i = 0; i < N; i++) { + newArray[i] = items[i] + } + }) +} + +const tryGc = () => { + // @ts-ignore + if (typeof global !== 'undefined' && global.gc) { + // @ts-ignore + global.gc() + } +} + +/** + * @param {t.TestCase} _tc + */ +export const testLargeFragmentedDocument = _tc => { + const itemsToInsert = largeDocumentSize + let update = /** @type {any} */ (null) + ;(() => { + const doc1 = new Y.Doc() + const text0 = doc1.get('txt') + tryGc() + t.measureTime(`time to insert ${itemsToInsert} items`, () => { + doc1.transact(() => { + for (let i = 0; i < itemsToInsert; i++) { + text0.insert(0, '0') + } + }) + }) + tryGc() + t.measureTime('time to encode document', () => { + update = Y.encodeStateAsUpdateV2(doc1) + }) + t.describe('Document size:', update.byteLength) + })() + ;(() => { + const doc2 = new Y.Doc() + tryGc() + t.measureTime(`time to apply ${itemsToInsert} updates`, () => { + Y.applyUpdateV2(doc2, update) + }) + })() +} + +/** + * @param {t.TestCase} _tc + */ +export const testIncrementalUpdatesPerformanceOnLargeFragmentedDocument = _tc => { + const itemsToInsert = largeDocumentSize + const updates = /** @type {Array>} */ ([]) + ;(() => { + const doc1 = new Y.Doc() + doc1.on('update', update => { + updates.push(update) + }) + const text0 = doc1.get('txt') + tryGc() + t.measureTime(`time to insert ${itemsToInsert} items`, () => { + doc1.transact(() => { + for (let i = 0; i < itemsToInsert; i++) { + text0.insert(0, '0') + } + }) + }) + tryGc() + })() + ;(() => { + t.measureTime(`time to merge ${itemsToInsert} updates (differential updates)`, () => { + Y.mergeUpdates(updates) + }) + tryGc() + t.measureTime(`time to merge ${itemsToInsert} updates (ydoc updates)`, () => { + const ydoc = new Y.Doc() + updates.forEach(update => { + Y.applyUpdate(ydoc, update) + }) + }) + })() +} + +/** + * Splitting surrogates can lead to invalid encoded documents. + * + * https://github.com/yjs/yjs/issues/248 + * + * @param {t.TestCase} tc + */ +export const testSplitSurrogateCharacter = tc => { + { + const { users, text0 } = init(tc, { users: 2 }) + users[1].disconnect() // disconnecting forces the user to encode the split surrogate + text0.insert(0, '👾') // insert surrogate character + // split surrogate, which should not lead to an encoding error + text0.insert(1, 'hi!') + compare(users) + } + { + const { users, text0 } = init(tc, { users: 2 }) + users[1].disconnect() // disconnecting forces the user to encode the split surrogate + text0.insert(0, '👾👾') // insert surrogate character + // partially delete surrogate + text0.delete(1, 2) + compare(users) + } + { + const { users, text0 } = init(tc, { users: 2 }) + users[1].disconnect() // disconnecting forces the user to encode the split surrogate + text0.insert(0, '👾👾') // insert surrogate character + // formatting will also split surrogates + text0.format(1, 2, { bold: true }) + compare(users) + } +} + +/** + * Search marker bug https://github.com/yjs/yjs/issues/307 + * + * @param {t.TestCase} tc + */ +export const testSearchMarkerBug1 = tc => { + const { users, text0, text1, testConnector } = init(tc, { users: 2 }) + + users[0].on('update', update => { + users[0].transact(() => { + Y.applyUpdate(users[0], update) + }) + }) + users[0].on('update', update => { + users[1].transact(() => { + Y.applyUpdate(users[1], update) + }) + }) + + text0.insert(0, 'a_a') + testConnector.flushAllMessages() + text0.insert(2, 's') + testConnector.flushAllMessages() + text1.insert(3, 'd') + testConnector.flushAllMessages() + text0.delete(0, 5) + testConnector.flushAllMessages() + text0.insert(0, 'a_a') + testConnector.flushAllMessages() + text0.insert(2, 's') + testConnector.flushAllMessages() + text1.insert(3, 'd') + testConnector.flushAllMessages() + t.compareStrings(text0.toString(), text1.toString()) + t.compareStrings(text0.toString(), 'a_sda') + compare(users) +} + +/** + * @param {t.TestCase} _tc + */ +export const testFormattingBug = async _tc => { + const ydoc1 = new Y.Doc() + const ydoc2 = new Y.Doc() + const text1 = ydoc1.get() + text1.insert(0, '\n\n\n') + text1.format(0, 3, { url: 'http://example.com' }) + ydoc1.get().format(1, 1, { url: 'http://docs.yjs.dev' }) + Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1)) + const text2 = ydoc2.get() + const expectedResult = delta.create() + .insert('\n', { url: 'http://example.com' }) + .insert('\n', { url: 'http://docs.yjs.dev' }) + .insert('\n', { url: 'http://example.com' }) + .done() + t.compare(text1.toDelta(), expectedResult) + t.compare(text1.toDelta().toJSON(), text2.toDelta().toJSON()) + console.log(text1.toDelta().toJSON()) +} + +/** + * Delete formatting should not leave redundant formatting items. + * + * @param {t.TestCase} _tc + */ +export const testDeleteFormatting = _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)) + + text.format(13, 7, { bold: true }) + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + text.format(16, 4, { bold: null }) + Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc)) + + const expected = delta.create() + .insert('Attack ships ') + .insert('on ', { bold: true }) + .insert('fire off the shoulder of Orion.') + .done() + t.compare(text.toDelta(), expected) + t.compare(text2.toDelta(), expected) +} + +/** + * @param {t.TestCase} _tc + */ +export const testAttributedContent = _tc => { + const ydoc = new Y.Doc({ gc: false }) + const ytext = ydoc.get() + ytext.insert(0, 'Hello World!') + 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('insert / delete / format', () => { + ytext.applyDelta(delta.create().retain(4, { italic: true }).retain(2).delete(5).insert('attributions').done()) + const expectedContent = delta.create().insert('Hell', { italic: true }, { format: { italic: [] } }).insert('o ').insert('World', {}, { delete: [] }).insert('attributions', {}, { insert: [] }).insert('!') + const attributedContent = ytext.toDelta({ renderer }) + console.log(attributedContent.toJSON()) + t.assert(attributedContent.equals(expectedContent)) + }) + t.group('unformat', () => { + ytext.applyDelta(delta.create().retain(5, { italic: null })) + const expectedContent = delta.create().insert('Hell', null, { format: { italic: [] } }).insert('o attributions!') + const attributedContent = ytext.toDelta({ renderer }) + console.log(attributedContent.toJSON()) + t.assert(attributedContent.equals(expectedContent)) + }) +} + +/** + * @param {t.TestCase} _tc + */ +export const testAttributedDiffing = _tc => { + const ydocVersion0 = new Y.Doc({ gc: false }) + ydocVersion0.clientID = 0 + ydocVersion0.get().insert(0, 'Hello World!') + const ydoc = new Y.Doc({ gc: false }) + ydoc.clientID = 1 + 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 to 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 = createIdMapFromIdSet(insertionSetDiff, [Y.createContentAttribute('insert', 'Bob')]) + const attributedDeletions = createIdMapFromIdSet(deleteSetDiff, [Y.createContentAttribute('delete', 'Bob')]) + // now we can define an attribution manager that maps these changes to output. One of the + // implementations is the AttributionsRenderer + const renderer = new AttributionsRenderer(Y.createContentMap(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)) + console.log(Y.encodeIdMap(attributedInsertions).length) +} + +/** + * The `renderedContent` option decouples "what is visible" from the doc's deleted state and from + * attributions: content in `renderedContent` renders normally (even if deleted → "restore"), alive + * content omitted from it is hidden, and attributions always render (even outside it). + * + * @param {t.TestCase} _tc + */ +export const testAttributionsRendererRenderedContent = _tc => { + const ydoc = new Y.Doc({ gc: false }) // restore requires gc:false (no rehydration of gc'd content) + ydoc.clientID = 1 + const ytext = ydoc.get() + ytext.insert(0, 'Hello World!') + ytext.delete(5, 6) // delete ' World' (clock 5..10) → the visible doc is 'Hello!' + t.compare(ytext.toDelta().toJSON(), delta.create().insert('Hello!').done().toJSON()) // sanity: default hides the deletion + + const emptyAttributions = Y.createContentMap() + const aliveSet = Y.createInsertSetFromStructStore(ydoc.store, true) // {0..4, 11} + + t.group('restore: deleted content in renderedContent renders normally', () => { + const renderedContent = Y.createInsertSetFromStructStore(ydoc.store, false) // all content incl. deleted + const renderer = new AttributionsRenderer(emptyAttributions, { renderedContent }) + const expected = delta.create().insert('Hello World!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) + + t.group('hide: alive content omitted from renderedContent renders as nothing', () => { + const hidden = Y.createIdSet() + hidden.add(1, 1, 4) // hide 'ello' (clock 1..4) + const renderer = new AttributionsRenderer(emptyAttributions, { renderedContent: Y.diffIdSet(aliveSet, hidden) }) + const expected = delta.create().insert('H!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) + + t.group('attributions always render, even outside renderedContent', () => { + const deletedRange = Y.createIdSet() + deletedRange.add(1, 5, 6) // ' World' + const attributions = Y.createContentMap(Y.createIdMap(), createIdMapFromIdSet(deletedRange, [Y.createContentAttribute('delete', 'bob')])) + const renderer = new AttributionsRenderer(attributions, { renderedContent: aliveSet }) + const expected = delta.create().insert('Hello').insert(' World', {}, { delete: ['bob'] }).insert('!') + const rendered = ytext.toDelta({ renderer }) + t.assert(rendered.equals(expected)) + }) +} + +// RANDOM TESTS + +let charCounter = 0 + +/** + * Random tests for pure text operations without formatting. + * + * @type Array + */ +const textChanges = [ + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // insert text + const ytext = y.get('text') + const insertPos = prng.int32(gen, 0, ytext.length) + const text = charCounter++ + prng.word(gen) + const prevText = ytext.toString() + ytext.insert(insertPos, text) + t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + text + prevText.slice(insertPos)) + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // delete text + const ytext = y.get('text') + const contentLen = ytext.toString().length + const insertPos = prng.int32(gen, 0, contentLen) + const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) + const prevText = ytext.toString() + ytext.delete(insertPos, overwrite) + t.compareStrings(ytext.toString(), prevText.slice(0, insertPos) + prevText.slice(insertPos + overwrite)) + } +] + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges5 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 5)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges30 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 30)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges40 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 40)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges50 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 50)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges70 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 70)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges90 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 90)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateTextChanges300 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, textChanges, 300)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +const marks = [ + { bold: true }, + { italic: true }, + { italic: true, color: '#888' } +] + +const marksChoices = [ + undefined, + ...marks +] + +/** + * Random tests for all features of y-text (formatting, embeds, ..). + * + * @type Array + */ +const qChanges = [ + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // insert text + const ytext = y.get('text') + const insertPos = prng.int32(gen, 0, ytext.length) + const attrs = prng.oneOf(gen, marksChoices) + const text = charCounter++ + prng.word(gen) + ytext.insert(insertPos, text, attrs) + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // insert embed + const ytext = y.get('text') + const insertPos = prng.int32(gen, 0, ytext.length) + if (prng.bool(gen)) { + ytext.insert(insertPos, [{ image: 'https://user-images.githubusercontent.com/5553757/48975307-61efb100-f06d-11e8-9177-ee895e5916e5.png' }]) + } else { + ytext.insert(insertPos, [delta.create().insert([prng.word(gen), prng.word(gen)])]) + } + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // delete text + const ytext = y.get('text') + const contentLen = ytext.length + const insertPos = prng.int32(gen, 0, contentLen) + const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) + ytext.delete(insertPos, overwrite) + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // format text + const ytext = y.get('text') + const contentLen = ytext.length + const insertPos = prng.int32(gen, 0, contentLen) + const overwrite = math.min(prng.int32(gen, 0, contentLen - insertPos), 2) + const format = prng.oneOf(gen, marks) + ytext.format(insertPos, overwrite, format) + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // insert codeblock + const ytext = y.get('text') + const insertPos = prng.int32(gen, 0, ytext.length) + const text = charCounter++ + prng.word(gen) + const d = delta.create() + d.retain(insertPos).insert(text).insert('\n', { 'code-block': true }) + ytext.applyDelta(d) + }, + /** + * @param {Y.Doc} y + * @param {prng.PRNG} gen + */ + (y, gen) => { // complex delta op + const ytext = y.get('text') + const contentLen = ytext.length + let currentPos = math.max(0, prng.int32(gen, 0, contentLen - 1)) + const d = delta.create().retain(currentPos) + // create max 3 ops + for (let i = 0; i < 7 && currentPos < contentLen; i++) { + prng.oneOf(gen, [ + () => { // format + const retain = math.min(prng.int32(gen, 0, contentLen - currentPos), 5) + const format = prng.oneOf(gen, marks) + d.retain(retain, format) + currentPos += retain + }, + () => { // insert + const attrs = prng.oneOf(gen, marksChoices) + const text = prng.word(gen, 1, 3) + d.insert(text, attrs) + }, + () => { // delete + const delLen = math.min(prng.int32(gen, 0, contentLen - currentPos), 10) + d.delete(delLen) + currentPos += delLen + } + ])() + } + ytext.applyDelta(d) + } +] + +/** + * @param {Y.ApplyRandomTestsResult} result + */ +const checkResult = result => { + for (let i = 1; i < result.testObjects.length; i++) { + // t.info('length of text = ' + result.users[i - 1].get('text').length) + const p1 = result.users[i - 1].get('text').toDeltaDeep().children + const p2 = result.users[i].get('text').toDeltaDeep().children + t.compare(p1, p2) + } + // Uncomment this to find formatting-cleanup issues + // const cleanups = Y.cleanupYTextFormatting(result.users[0].getText('text')) + // t.assert(cleanups === 0) + return result +} + +/** + * Generally, the content reader with attributed content works better if there is more deleted + * content. Increasing the number of deletions improves performance of toDelta (even to the point + * that is beats toString(), but that might be because of internal js optimization steps). + * + * @param {t.TestCase} tc + */ +export const testRendererDefaultPerformance = tc => { + const N = 10000 + const MaxDeletionLength = 5 // 25% chance of deletion + const MaxInsertionLength = 5 + const ydoc = new Y.Doc() + const ytext = ydoc.get() + for (let i = 0; i < N; i++) { + if (prng.bool(tc.prng) && prng.bool(tc.prng) && ytext.length > 0) { + const index = prng.int31(tc.prng, 0, ytext.length - 1) + const len = prng.int31(tc.prng, 0, math.min(ytext.length - index, MaxDeletionLength)) + ytext.delete(index, len) + } else { + const index = prng.int31(tc.prng, 0, ytext.length) + const content = prng.utf16String(tc.prng, MaxInsertionLength) + ytext.insert(index, content) + } + } + t.info(`number of changes: ${N / 1000}k`) + t.info(`length of text: ${ytext.length}`) + const M = 100 + t.measureTime(`original toString perf `, () => { + for (let i = 0; i < M; i++) { + ytext.toString() + } + }) + t.measureTime(`toDelta(renderer) performance `, () => { + for (let i = 0; i < M; i++) { + ytext.toDelta() + } + }) +} + +/** + * Benchmark the generic fast path (default / no renderer) against an {@link AttributionsRenderer} + * configured with *no* attributions (an empty {@link ContentMap}) and a custom `renderedContent` + * set to the doc's full insert set. Since the insert set also contains the ids of deleted content, + * the renderer *restores* every deletion — rendering the whole document normally, without + * attributions — while alive content (fully covered by `renderedContent`) still takes the generic + * fast path. This measures the renderer's restore path (`hasItem`'s restore branch, `readContent`'s + * custom-`renderedContent` path, and `contentLength` via `coveredLength`) against the default. + * + * @param {t.TestCase} tc + */ +export const testRendererAttributionsPerformance = tc => { + const N = 10000 + const MaxDeletionLength = 5 // 25% chance of deletion + const MaxInsertionLength = 5 + const ydoc = new Y.Doc({ gc: false }) // keep deleted content so the delete set renders real content + const ytext = ydoc.get() + for (let i = 0; i < N; i++) { + if (prng.bool(tc.prng) && prng.bool(tc.prng) && ytext.length > 0) { + const index = prng.int31(tc.prng, 0, ytext.length - 1) + const len = prng.int31(tc.prng, 0, math.min(ytext.length - index, MaxDeletionLength)) + ytext.delete(index, len) + } else { + const index = prng.int31(tc.prng, 0, ytext.length) + const content = prng.utf16String(tc.prng, MaxInsertionLength) + ytext.insert(index, content) + } + } + // no attributions; renderedContent = the full insert set, which also covers deleted ids, so every + // deletion is restored and rendered normally + const insertSet = Y.createInsertSetFromStructStore(ydoc.store, false) + // const deleteSet = Y.createDeleteSetFromStructStore(ydoc.store) + const renderer = new AttributionsRenderer(Y.createContentMap(), { renderedContent: insertSet }) + t.info(`number of changes: ${N / 1000}k`) + t.info(`length of visible text: ${ytext.length}`) + const M = 10 + t.measureTime(`default renderer (fast path): toDelta() `, () => { + for (let i = 0; i < M; i++) { + ytext.toDelta() + } + }) + t.measureTime(`AttributionsRenderer (no attributions, renderedContent = insert set): toDelta({ renderer }) `, () => { + for (let i = 0; i < M; i++) { + ytext.toDelta({ renderer }) + } + }) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges1 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 1)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges2 = tc => { + const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges2Repeat = tc => { + for (let i = 0; i < 1000; i++) { + const { users } = checkResult(Y.applyRandomTests(tc, qChanges, 2)) + const cleanups = Y.cleanupYTextFormatting(users[0].get('text')) + t.assert(cleanups === 0) + } +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges3 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 3)) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges30 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 30)) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges40 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 40)) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges70 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 70)) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges100 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 100)) +} + +/** + * @param {t.TestCase} tc + */ +export const testRepeatGenerateQuillChanges300 = tc => { + checkResult(Y.applyRandomTests(tc, qChanges, 300)) +} diff --git a/tests/y-xml.tests.js b/tests/y-xml.tests.js new file mode 100644 index 0000000..f8dea10 --- /dev/null +++ b/tests/y-xml.tests.js @@ -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} */ (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 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} 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' + ) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f5a725b --- /dev/null +++ b/tsconfig.json @@ -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"] +}