Rebasely Beta: data may be purged

examples/live-notes Public

Add edit operations and their transform

Notes will be edited by several people at once, so edits travel as insert and delete operations at an offset. transform() moves one op past another written against the same text, and the tests check that every pair of concurrent ops converges in either order.
50b1d73e2d8d9138fc4484c69fcb47b7b3f8683e
The first commit Browse filesHistory

Changed files

9 files changed715 additions0 deletionsin the first commit

  1. Added.gitignore+3−0
  2. AddedLICENSE+21−0
  3. AddedREADME.md+11−0
  4. Addedpackage-lock.json+420−0
  5. Addedpackage.json+20−0
  6. Addedsrc/ops.ts+101−0
  7. Addedtest/helpers/random.ts+23−0
  8. Addedtest/ops.test.ts+100−0
  9. Addedtsconfig.json+16−0
Added.gitignore+3−0
@@ -0,0 +1,3 @@
+node_modules/
+.wrangler/
+.dev.vars
View file
AddedLICENSE+21−0
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Justin Torre
+
+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.
View file
AddedREADME.md+11−0
@@ -0,0 +1,11 @@
+# live-notes
+
+Plain-text notes that several people can edit at once, on Cloudflare Durable Objects.
+
+This first part is the edit model: insert and delete operations and the transform that makes
+concurrent edits converge (`src/ops.ts`).
+
+```bash
+npm install
+npm test
+```
View file
Addedpackage-lock.jsonLong diff, collapsed+420−0
@@ -0,0 +1,420 @@
+{
+ "name": "live-notes",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "live-notes",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20261016.1",
+ "@types/node": "^24.19.2",
+ "typescript": "^7.0.2"
+ },
+ "engines": {
+ "node": ">=24"
+ }
+ },
+ "node_modules/@cloudflare/workers-types": {
+ "version": "5.20261016.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20261016.1.tgz",
+ "integrity": "sha512-I8OJdPl3M6TIl9ksU3wtEyWVhSE8E8txJ/vGOv3SkhjcvN8uMjyFmMvRsNOZgzMe5xYFQ0LctmiGqTwR7DbZ6w==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0"
+ },
+ "node_modules/@types/node": {
+ "version": "24.19.2",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
+ "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
View file
Addedpackage.json+20−0
@@ -0,0 +1,20 @@
+{
+ "name": "live-notes",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Collaborative plain-text notes on Cloudflare Durable Objects and WebSockets.",
+ "license": "MIT",
+ "type": "module",
+ "engines": {
+ "node": ">=24"
+ },
+ "scripts": {
+ "test": "node --disable-warning=ExperimentalWarning --test \"test/**/*.test.ts\"",
+ "typecheck": "tsc"
+ },
+ "devDependencies": {
+ "@cloudflare/workers-types": "^5.20261016.1",
+ "@types/node": "^24.19.2",
+ "typescript": "^7.0.2"
+ }
+}
View file
Addedsrc/ops.ts+101−0
@@ -0,0 +1,101 @@
+// Edit operations on a plain-text note, and the transform that lets two editors' concurrent
+// edits land in the same order everywhere. Offsets count UTF-16 code units, the same units a
+// textarea's selectionStart uses, so the browser and the room agree on every position.
+//
+// There are only two kinds of operation. A replacement (typing over a selection) is a delete
+// followed by an insert.
+
+export type Insert = { type: "insert"; pos: number; text: string };
+export type Delete = { type: "delete"; pos: number; count: number };
+export type Op = Insert | Delete;
+
+export class OpError extends Error {}
+
+export function insert(pos: number, text: string): Insert {
+ return { type: "insert", pos, text };
+}
+
+export function del(pos: number, count: number): Delete {
+ return { type: "delete", pos, count };
+}
+
+// An op that changes nothing: an empty insert, or a delete of zero characters. Transform
+// produces these when another edit already removed the text an op was about.
+export function isNoop(op: Op): boolean {
+ return op.type === "insert" ? op.text.length === 0 : op.count === 0;
+}
+
+// Applies an op to a document. Throws OpError if the op does not fit the document, which means
+// the sender and the room disagree about the text; the caller should resync that sender.
+export function apply(doc: string, op: Op): string {
+ if (!Number.isInteger(op.pos) || op.pos < 0 || op.pos > doc.length) {
+ throw new OpError(`${op.type} at ${op.pos} is outside a document of length ${doc.length}`);
+ }
+ if (op.type === "insert") return doc.slice(0, op.pos) + op.text + doc.slice(op.pos);
+ if (!Number.isInteger(op.count) || op.count < 0 || op.pos + op.count > doc.length) {
+ throw new OpError(`delete of ${op.count} at ${op.pos} runs past a document of length ${doc.length}`);
+ }
+ return doc.slice(0, op.pos) + doc.slice(op.pos + op.count);
+}
+
+// Rewrites `op` so it can be applied after `other`, where both were written against the same
+// document. `otherFirst` says which of the two the room sequenced first. It only matters when
+// both insert at the same offset: the one sequenced first ends up first in the text, so the room
+// passes true (the logged op came first) and an editor passes false when it moves a remote op
+// past its own unacknowledged edits.
+//
+// Two rules keep this small:
+// - An insert that lands strictly inside a range someone else deleted is dropped (it becomes a
+// no-op), and the delete grows to swallow it. Both orders then agree on the result.
+// - Deletes that overlap only remove what is still there.
+export function transform(op: Op, other: Op, otherFirst: boolean): Op {
+ if (op.type === "insert") {
+ if (other.type === "insert") {
+ const shift = other.pos < op.pos || (other.pos === op.pos && otherFirst);
+ return shift ? insert(op.pos + other.text.length, op.text) : op;
+ }
+ const end = other.pos + other.count;
+ if (op.pos <= other.pos) return op;
+ if (op.pos >= end) return insert(op.pos - other.count, op.text);
+ return insert(other.pos, "");
+ }
+
+ const end = op.pos + op.count;
+ if (other.type === "insert") {
+ if (other.pos <= op.pos) return del(op.pos + other.text.length, op.count);
+ if (other.pos >= end) return op;
+ return del(op.pos, op.count + other.text.length);
+ }
+
+ const otherEnd = other.pos + other.count;
+ if (end <= other.pos) return op;
+ if (op.pos >= otherEnd) return del(op.pos - other.count, op.count);
+ const overlap = Math.min(end, otherEnd) - Math.max(op.pos, other.pos);
+ return del(Math.min(op.pos, other.pos), op.count - overlap);
+}
+
+// Moves `op` past a run of ops the room sequenced before it, in order.
+export function transformPast(op: Op, ops: readonly Op[]): Op {
+ let out = op;
+ for (const other of ops) out = transform(out, other, true);
+ return out;
+}
+
+// The ops that turn `before` into `after`, found by trimming the common prefix and suffix. A
+// textarea's input event changes one contiguous range, so this is exact for typing, pasting
+// and deleting a selection.
+export function diff(before: string, after: string): Op[] {
+ let start = 0;
+ const max = Math.min(before.length, after.length);
+ while (start < max && before[start] === after[start]) start++;
+ let endBefore = before.length;
+ let endAfter = after.length;
+ while (endBefore > start && endAfter > start && before[endBefore - 1] === after[endAfter - 1]) {
+ endBefore--;
+ endAfter--;
+ }
+ const ops: Op[] = [];
+ if (endBefore > start) ops.push(del(start, endBefore - start));
+ if (endAfter > start) ops.push(insert(start, after.slice(start, endAfter)));
+ return ops;
+}
View file
Addedtest/helpers/random.ts+23−0
@@ -0,0 +1,23 @@
+import { del, insert, type Op } from "../../src/ops.ts";
+
+// A small seeded generator (mulberry32) so a failing case can be replayed.
+export function rng(seed: number): () => number {
+ let s = seed >>> 0;
+ return () => {
+ s = (s + 0x6d2b79f5) >>> 0;
+ let t = s;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+// A random op that fits `doc`.
+export function randomOp(doc: string, next: () => number): Op {
+ const pos = Math.floor(next() * (doc.length + 1));
+ if (doc.length === pos || next() < 0.5) {
+ const words = ["a", "bc", "def", "\n", " ", "xyz!"];
+ return insert(pos, words[Math.floor(next() * words.length)]);
+ }
+ return del(pos, 1 + Math.floor(next() * Math.min(4, doc.length - pos)));
+}
View file
Addedtest/ops.test.ts+100−0
@@ -0,0 +1,100 @@
+import assert from "node:assert/strict";
+import { describe, test } from "node:test";
+import { OpError, apply, del, diff, insert, isNoop, transform, transformPast, type Op } from "../src/ops.ts";
+import { randomOp, rng } from "./helpers/random.ts";
+
+describe("apply", () => {
+ test("inserts and deletes at an offset", () => {
+ assert.equal(apply("milk eggs", insert(5, "bread ")), "milk bread eggs");
+ assert.equal(apply("milk bread eggs", del(4, 6)), "milk eggs");
+ assert.equal(apply("", insert(0, "hello")), "hello");
+ });
+
+ test("refuses an op that does not fit the document", () => {
+ assert.throws(() => apply("abc", insert(4, "x")), OpError);
+ assert.throws(() => apply("abc", insert(-1, "x")), OpError);
+ assert.throws(() => apply("abc", del(2, 2)), OpError);
+ assert.throws(() => apply("abc", del(1.5, 1)), OpError);
+ });
+});
+
+describe("transform", () => {
+ test("an insert before another shifts it right", () => {
+ assert.deepEqual(transform(insert(5, "b"), insert(2, "aa"), true), insert(7, "b"));
+ assert.deepEqual(transform(insert(2, "aa"), insert(5, "b"), false), insert(2, "aa"));
+ });
+
+ test("inserts at the same offset are ordered by the room", () => {
+ // The logged op came first, so the incoming one goes after it.
+ assert.deepEqual(transform(insert(3, "B"), insert(3, "A"), true), insert(4, "B"));
+ // An editor moving the room's op past its own pending insert keeps the room's op first.
+ assert.deepEqual(transform(insert(3, "A"), insert(3, "B"), false), insert(3, "A"));
+ });
+
+ test("an insert after a delete moves left by the deleted length", () => {
+ assert.deepEqual(transform(insert(8, "x"), del(2, 3), true), insert(5, "x"));
+ assert.deepEqual(transform(insert(2, "x"), del(2, 3), true), insert(2, "x"));
+ assert.deepEqual(transform(insert(5, "x"), del(2, 3), true), insert(2, "x"));
+ });
+
+ test("an insert inside a deleted range is dropped and the delete swallows it", () => {
+ const ins = insert(3, "XY");
+ const d = del(1, 4);
+ const ins2 = transform(ins, d, true);
+ assert.ok(isNoop(ins2));
+ assert.deepEqual(transform(d, ins, false), del(1, 6));
+ assert.equal(apply(apply("abcdefg", d), ins2), "afg");
+ assert.equal(apply(apply("abcdefg", ins), transform(d, ins, false)), "afg");
+ });
+
+ test("overlapping deletes remove the text once", () => {
+ assert.deepEqual(transform(del(1, 3), del(2, 4), true), del(1, 1));
+ assert.deepEqual(transform(del(2, 4), del(1, 3), false), del(1, 2));
+ assert.deepEqual(transform(del(2, 2), del(1, 5), true), del(1, 0));
+ assert.deepEqual(transform(del(6, 2), del(1, 3), true), del(3, 2));
+ assert.deepEqual(transform(del(0, 1), del(1, 3), true), del(0, 1));
+ });
+
+ test("two concurrent ops converge in either order", () => {
+ const next = rng(7);
+ for (let i = 0; i < 5000; i++) {
+ const doc = "the quick brown fox".slice(0, Math.floor(next() * 20));
+ const a = randomOp(doc, next);
+ const b = randomOp(doc, next);
+ // The room sequenced a first: b is moved past a. An editor that already applied b
+ // receives a and moves it past b.
+ const viaA = apply(apply(doc, a), transform(b, a, true));
+ const viaB = apply(apply(doc, b), transform(a, b, false));
+ assert.equal(viaA, viaB, `doc ${JSON.stringify(doc)} a ${JSON.stringify(a)} b ${JSON.stringify(b)}`);
+ }
+ });
+});
+
+describe("transformPast", () => {
+ test("moves an op past a run of logged ops in order", () => {
+ const logged: Op[] = [insert(0, "## "), del(10, 2), insert(3, "Groceries\n")];
+ assert.deepEqual(transformPast(insert(12, "!"), logged), insert(23, "!"));
+ });
+});
+
+describe("diff", () => {
+ test("finds a typed character", () => {
+ assert.deepEqual(diff("helo", "hello"), [insert(3, "l")]);
+ });
+
+ test("finds a deleted selection", () => {
+ assert.deepEqual(diff("milk, eggs", "milk"), [del(4, 6)]);
+ });
+
+ test("a replaced selection is a delete then an insert", () => {
+ const ops = diff("buy milk today", "buy oat milk today");
+ assert.deepEqual(ops, [insert(4, "oat ")]);
+ const replaced = diff("buy milk today", "buy bread today");
+ assert.deepEqual(replaced, [del(4, 4), insert(4, "bread")]);
+ assert.equal(replaced.reduce(apply, "buy milk today"), "buy bread today");
+ });
+
+ test("no change, no ops", () => {
+ assert.deepEqual(diff("same", "same"), []);
+ });
+});
View file
Addedtsconfig.json+16−0
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "es2023",
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "lib": ["es2023"],
+ "types": ["@cloudflare/workers-types", "node"],
+ "strict": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "erasableSyntaxOnly": true,
+ "skipLibCheck": true
+ },
+ "include": ["src", "test"]
+}
View file

Checks

No checks reported for this commit.

Runs

No runs for this commit.