diff --git a/GNUmakefile b/GNUmakefile index 88c11229e5..2729007970 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -22,6 +22,7 @@ GOLINTCMD ?= golangci-lint SWIFTLINTCMD ?= swiftlint SWIFTCMD ?= swift RUST_TOOLCHAIN ?= 1.89.0 +AUTOMERGE_FUZZ_TIME ?= 10s SWIFT_ENROLL_UI ?= cmd/probo-agent/installer/macos/enroll-ui SWIFT_FORMAT_CONFIG ?= .swift-format SWIFTLINT_CONFIG ?= .swiftlint.yml @@ -219,6 +220,34 @@ test-automerge-conformance: ## Test Go binary compatibility with official Autome AUTOMERGE_JS_ORACLE=$(CURDIR)/packages/automerge-conformance/oracle.mjs \ $(GO_BASE) test -count=1 ./pkg/automerge +.PHONY: audit-automerge-parity +audit-automerge-parity: test-automerge-conformance +audit-automerge-parity: ## Require every pinned upstream Automerge test to be mapped + AUTOMERGE_REQUIRE_FULL_PARITY=1 \ + $(GO_BASE) test -count=1 -run '^TestUpstreamParityManifest$$' ./pkg/automerge + +.PHONY: audit-automerge-interop +audit-automerge-interop: test-automerge-conformance +audit-automerge-interop: ## Require complete Rust/JS wire and state interoperability + AUTOMERGE_REQUIRE_FULL_INTEROP=1 \ + $(GO_BASE) test -count=1 -run '^TestUpstreamParityManifest$$' ./pkg/automerge + +.PHONY: benchmark-automerge +benchmark-automerge: ## Benchmark native and Rust/WASM Automerge engines + $(GO_BASE) test -run '^$$' -bench . -benchmem ./pkg/automerge + +.PHONY: benchmark-automerge-native +benchmark-automerge-native: ## Compare optimized native Go and native Rust + $(NPM) -w @probo/automerge-benchmark run compare + +.PHONY: fuzz-automerge +fuzz-automerge: ## Fuzz Automerge public, wire, sync, and projection surfaces + $(GO_BASE) test -run '^$$' -fuzz '^FuzzLoad$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge + $(GO_BASE) test -run '^$$' -fuzz '^FuzzCoreOperations$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge + $(GO_BASE) test -run '^$$' -fuzz '^FuzzDecode$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge/internal/native + $(GO_BASE) test -run '^$$' -fuzz '^FuzzParseSyncMessage$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge/internal/native + $(GO_BASE) test -run '^$$' -fuzz '^FuzzRender$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge/prosemirror + .PHONY: coverage-report coverage-report: test ## Generate HTML coverage report $(GO) tool cover -html=coverage.out -o coverage.html diff --git a/go.mod b/go.mod index b8e97c94e8..a3452820a2 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/pdfcpu/pdfcpu v0.13.0 github.com/pires/go-proxyproto v0.15.0 github.com/prometheus/client_golang v1.24.1 + github.com/rivo/uniseg v0.4.7 github.com/scim2/filter-parser/v2 v2.3.1 github.com/sigstore/sigstore-go v1.3.0 github.com/stretchr/testify v1.11.1 @@ -137,7 +138,6 @@ require ( github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/q-uint/parser v0.3.1 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect diff --git a/package-lock.json b/package-lock.json index c3fc18f127..e68b140f15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5471,6 +5471,10 @@ "integrity": "sha512-4PZ9wMYI8m8AqJuZ9YR1IAHGVtSnYbBVBgTevrKpzZbcSe/OUwhEV2Ks//rJh/L8eMTU8R5DrD4D/hlrOwaAiQ==", "license": "MIT" }, + "node_modules/@probo/automerge-benchmark": { + "resolved": "packages/automerge-benchmark", + "link": true + }, "node_modules/@probo/automerge-conformance": { "resolved": "packages/automerge-conformance", "link": true @@ -22672,6 +22676,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/automerge-benchmark": { + "name": "@probo/automerge-benchmark", + "version": "1.0.0", + "license": "MIT" + }, "packages/automerge-conformance": { "name": "@probo/automerge-conformance", "version": "1.0.0", diff --git a/packages/automerge-benchmark/.gitignore b/packages/automerge-benchmark/.gitignore new file mode 100644 index 0000000000..4fb663ddfa --- /dev/null +++ b/packages/automerge-benchmark/.gitignore @@ -0,0 +1,2 @@ +/rust/target/ +/go/probo-automerge-go-benchmark diff --git a/packages/automerge-benchmark/compare.mjs b/packages/automerge-benchmark/compare.mjs new file mode 100644 index 0000000000..ae37432026 --- /dev/null +++ b/packages/automerge-benchmark/compare.mjs @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const directory = path.dirname(fileURLToPath(import.meta.url)); +const repository = path.resolve(directory, "../.."); +const goBinary = path.join(os.tmpdir(), "probo-automerge-go-benchmark"); +const fixture = path.join(os.tmpdir(), "probo-automerge-benchmark-fixture"); +const rustManifest = path.join(directory, "rust", "Cargo.toml"); +const rustBinary = path.join( + directory, + "rust", + "target", + "release", + "probo-automerge-native-benchmark", +); + +const scenarios = [ + { workload: "create", size: 0, iterations: 100_000 }, + { workload: "map", size: 100, iterations: 500 }, + { workload: "map", size: 1_000, iterations: 30 }, + { workload: "text", size: 100, iterations: 500 }, + { workload: "text", size: 1_000, iterations: 30 }, + { workload: "load", size: 10_000, iterations: 50 }, + { workload: "save", size: 10_000, iterations: 1_000 }, +]; +const samples = 3; +const goVersion = execFileSync("go", ["version"], { encoding: "utf8" }).trim(); +const rustVersion = execFileSync("rustc", ["+1.89.0", "--version"], { + encoding: "utf8", +}).trim(); + +execFileSync( + "go", + ["build", "-trimpath", "-ldflags=-s -w", "-o", goBinary, "./packages/automerge-benchmark/go"], + { cwd: repository, stdio: "inherit" }, +); +execFileSync( + "cargo", + [ + "+1.89.0", + "build", + "--release", + "--locked", + "--manifest-path", + rustManifest, + ], + { cwd: repository, stdio: "inherit" }, +); +execFileSync( + goBinary, + [ + "--workload", + "fixture", + "--size", + "10000", + "--fixture", + fixture, + ], + { stdio: "inherit" }, +); + +const rows = []; +try { + for (const scenario of scenarios) { + const go = measure(goBinary, scenario); + const rust = measure(rustBinary, scenario); + if (go.checksum !== rust.checksum) { + throw new Error( + `checksum mismatch for ${scenario.workload}/${scenario.size}: Go ${go.checksum}, Rust ${rust.checksum}`, + ); + } + + rows.push({ + workload: scenario.workload, + size: scenario.size, + goNS: go.ns, + rustNS: rust.ns, + ratio: rust.ns / go.ns, + }); + } +} finally { + fs.rmSync(goBinary, { force: true }); + fs.rmSync(fixture, { force: true }); +} + +process.stdout.write(`Host: ${os.platform()}/${os.arch()} β€” ${os.cpus()[0]?.model ?? "unknown CPU"}\n`); +process.stdout.write(`Go: ${goVersion}\n`); +process.stdout.write(`Rust: ${rustVersion}\n`); +process.stdout.write(`Samples: ${samples} (median reported)\n\n`); +process.stdout.write("| Workload | Size | Native Go | Native Rust | Rust/Go |\n"); +process.stdout.write("|---|---:|---:|---:|---:|\n"); +for (const row of rows) { + process.stdout.write( + `| ${row.workload} | ${row.size || "β€”"} | ${duration(row.goNS)} | ${duration(row.rustNS)} | ${row.ratio.toFixed(2)}x |\n`, + ); +} + +function measure(binary, scenario) { + const values = []; + let checksum; + for (let sample = 0; sample < samples; sample++) { + const commandArguments = [ + "--workload", + scenario.workload, + "--size", + String(scenario.size), + "--iterations", + String(scenario.iterations), + "--warmups", + "3", + ]; + if (scenario.workload === "load" || scenario.workload === "save") { + commandArguments.push("--fixture", fixture); + } + const output = execFileSync( + binary, + commandArguments, + { encoding: "utf8" }, + ); + const result = JSON.parse(output); + values.push(Number(result.nsPerOp)); + checksum ??= result.checksum; + if (checksum !== result.checksum) { + throw new Error( + `${binary} produced unstable checksums for ${scenario.workload}/${scenario.size}`, + ); + } + } + values.sort((left, right) => left - right); + return { + checksum, + ns: values[Math.floor(values.length / 2)], + }; +} + +function duration(nanoseconds) { + if (nanoseconds < 1_000) return `${nanoseconds.toFixed(0)} ns`; + if (nanoseconds < 1_000_000) return `${(nanoseconds / 1_000).toFixed(2)} Β΅s`; + if (nanoseconds < 1_000_000_000) { + return `${(nanoseconds / 1_000_000).toFixed(2)} ms`; + } + return `${(nanoseconds / 1_000_000_000).toFixed(2)} s`; +} diff --git a/packages/automerge-benchmark/go/main.go b/packages/automerge-benchmark/go/main.go new file mode 100644 index 0000000000..cb52079ae2 --- /dev/null +++ b/packages/automerge-benchmark/go/main.go @@ -0,0 +1,455 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "time" + + "go.probo.inc/probo/pkg/automerge" +) + +type result struct { + Workload string `json:"workload"` + Size int `json:"size"` + Iterations int `json:"iterations"` + TotalNS int64 `json:"totalNs"` + NSPerOp int64 `json:"nsPerOp"` + Checksum string `json:"checksum"` +} + +type benchmarkWorkload struct { + run func() error + validate func() (string, error) + cleanup func() +} + +var benchmarkActor = automerge.ActorID{ + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 10, 11, 12, 13, 14, 15, +} + +func main() { + workload := flag.String("workload", "", "benchmark workload") + size := flag.Int("size", 0, "workload size") + iterations := flag.Int("iterations", 1, "timed iterations") + warmups := flag.Int("warmups", 3, "warmup iterations") + fixture := flag.String("fixture", "", "shared fixture path") + + flag.Parse() + + if *workload == "" || *iterations <= 0 || *warmups < 0 { + fmt.Fprintln(os.Stderr, "invalid benchmark arguments") + os.Exit(2) + } + + if *workload == "fixture" { + if *fixture == "" { + fmt.Fprintln(os.Stderr, "fixture path is required") + os.Exit(2) + } + + document, err := fixtureDocument(*size) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + data, err := document.Save(context.Background()) + _ = document.Close(context.Background()) + + if err == nil { + err = os.WriteFile(*fixture, data, 0o600) + } + + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + return + } + + runner, err := workloadRunner(*workload, *size, *fixture) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer runner.cleanup() + + for range *warmups { + if err := runner.run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + + startedAt := time.Now() + + for range *iterations { + if err := runner.run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + + total := time.Since(startedAt) + + checksum, err := runner.validate() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if err := json.NewEncoder(os.Stdout).Encode(result{ + Workload: *workload, + Size: *size, + Iterations: *iterations, + TotalNS: total.Nanoseconds(), + NSPerOp: total.Nanoseconds() / int64(*iterations), + Checksum: checksum, + }); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func workloadRunner( + workload string, + size int, + fixture string, +) (benchmarkWorkload, error) { + switch workload { + case "create": + return benchmarkWorkload{ + run: func() error { + document, err := automerge.New( + context.Background(), + benchmarkActor, + ) + if err != nil { + return err + } + + return document.Close(context.Background()) + }, + validate: func() (string, error) { + return checksum([]byte("empty")), nil + }, + cleanup: func() {}, + }, nil + case "map": + return benchmarkWorkload{ + run: func() error { + document, err := mapDocument(size) + if err != nil { + return err + } + + return document.Close(context.Background()) + }, + validate: func() (string, error) { + document, err := mapDocument(size) + if err != nil { + return "", err + } + + defer func() { _ = document.Close(context.Background()) }() + + return mapChecksum(document, size) + }, + cleanup: func() {}, + }, nil + case "text": + return benchmarkWorkload{ + run: func() error { + document, err := typedDocument(size) + if err != nil { + return err + } + + return document.Close(context.Background()) + }, + validate: func() (string, error) { + document, err := typedDocument(size) + if err != nil { + return "", err + } + + defer func() { _ = document.Close(context.Background()) }() + + return textChecksum(document) + }, + cleanup: func() {}, + }, nil + case "load": + data, err := fixtureData(size, fixture) + if err != nil { + return benchmarkWorkload{}, err + } + + return benchmarkWorkload{ + run: func() error { + loaded, err := automerge.Load( + context.Background(), + data, + benchmarkActor, + ) + if err != nil { + return err + } + + return loaded.Close(context.Background()) + }, + validate: func() (string, error) { + loaded, err := automerge.Load( + context.Background(), + data, + benchmarkActor, + ) + if err != nil { + return "", err + } + + defer func() { _ = loaded.Close(context.Background()) }() + + return textChecksum(loaded) + }, + cleanup: func() {}, + }, nil + case "save": + data, err := fixtureData(size, fixture) + if err != nil { + return benchmarkWorkload{}, err + } + + document, err := automerge.Load( + context.Background(), + data, + benchmarkActor, + ) + if err != nil { + return benchmarkWorkload{}, err + } + + return benchmarkWorkload{ + run: func() error { + _, err := document.Save(context.Background()) + + return err + }, + validate: func() (string, error) { + data, err := document.Save(context.Background()) + if err != nil { + return "", err + } + + loaded, err := automerge.Load( + context.Background(), + data, + benchmarkActor, + ) + if err != nil { + return "", err + } + + defer func() { _ = loaded.Close(context.Background()) }() + + return textChecksum(loaded) + }, + cleanup: func() { + _ = document.Close(context.Background()) + }, + }, nil + default: + return benchmarkWorkload{}, fmt.Errorf("unknown workload %q", workload) + } +} + +func mapDocument(size int) (*automerge.Document, error) { + ctx := context.Background() + + document, err := automerge.New(ctx, benchmarkActor) + if err != nil { + return nil, err + } + + values, err := document.Root().CreateObject( + ctx, + "values", + automerge.ObjectTypeMap, + ) + if err != nil { + _ = document.Close(ctx) + return nil, err + } + + for index := range size { + if err := values.PutScalar( + ctx, + strconv.Itoa(index), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: int64(index), + }, + ); err != nil { + _ = document.Close(ctx) + return nil, err + } + } + + if _, err := document.Commit(ctx, "benchmark", time.Time{}); err != nil { + _ = document.Close(ctx) + return nil, err + } + + return document, nil +} + +func typedDocument(size int) (*automerge.Document, error) { + ctx := context.Background() + + document, err := automerge.New(ctx, benchmarkActor) + if err != nil { + return nil, err + } + + text, err := document.CreateText(ctx, "body") + if err != nil { + _ = document.Close(ctx) + return nil, err + } + + for index := range size { + if err := text.Splice(ctx, uint32(index), 0, "x"); err != nil { + _ = document.Close(ctx) + return nil, err + } + } + + if _, err := document.Commit(ctx, "benchmark", time.Time{}); err != nil { + _ = document.Close(ctx) + return nil, err + } + + return document, nil +} + +func fixtureDocument(size int) (*automerge.Document, error) { + ctx := context.Background() + + document, err := automerge.New(ctx, benchmarkActor) + if err != nil { + return nil, err + } + + text, err := document.CreateText(ctx, "body") + if err != nil { + _ = document.Close(ctx) + return nil, err + } + + if err := text.Splice(ctx, 0, 0, benchmarkText(size)); err != nil { + _ = document.Close(ctx) + return nil, err + } + + if _, err := document.Commit(ctx, "benchmark", time.Time{}); err != nil { + _ = document.Close(ctx) + return nil, err + } + + return document, nil +} + +func fixtureData(size int, file string) ([]byte, error) { + if file != "" { + return os.ReadFile(file) + } + + document, err := fixtureDocument(size) + if err != nil { + return nil, err + } + + defer func() { _ = document.Close(context.Background()) }() + + return document.Save(context.Background()) +} + +func benchmarkText(size int) string { + value := make([]byte, size) + for index := range value { + value[index] = byte('a' + index%26) + } + + return string(value) +} + +func mapChecksum(document *automerge.Document, size int) (string, error) { + values, err := document.Root().Object(context.Background(), "values") + if err != nil { + return "", err + } + + normalized := make([]byte, 0, size*16) + for index := range size { + value, err := values.Scalar( + context.Background(), + strconv.Itoa(index), + ) + if err != nil { + return "", err + } + + normalized = strconv.AppendInt(normalized, value.Int, 10) + normalized = append(normalized, '\n') + } + + return checksum(normalized), nil +} + +func textChecksum(document *automerge.Document) (string, error) { + text, err := document.Text(context.Background(), "body") + if err != nil { + return "", err + } + + value, err := text.String(context.Background()) + if err != nil { + return "", err + } + + return checksum([]byte(value)), nil +} + +func checksum(value []byte) string { + digest := sha256.Sum256(value) + + return hex.EncodeToString(digest[:]) +} diff --git a/packages/automerge-benchmark/package.json b/packages/automerge-benchmark/package.json new file mode 100644 index 0000000000..8cdc07236c --- /dev/null +++ b/packages/automerge-benchmark/package.json @@ -0,0 +1,11 @@ +{ + "name": "@probo/automerge-benchmark", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "check": "node --check compare.mjs", + "compare": "node compare.mjs" + } +} diff --git a/packages/automerge-benchmark/rust/Cargo.lock b/packages/automerge-benchmark/rust/Cargo.lock new file mode 100644 index 0000000000..e85e2f9b94 --- /dev/null +++ b/packages/automerge-benchmark/rust/Cargo.lock @@ -0,0 +1,472 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "automerge" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633" +dependencies = [ + "cfg-if", + "flate2", + "getrandom", + "hex", + "hexane", + "itertools", + "leb128", + "rand", + "rustc-hash", + "serde", + "sha2", + "smol_str", + "thiserror", + "tinyvec", + "tracing", + "unicode-segmentation", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexane" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4ecba0bb4e14df997df7cab6d1b584c6432d8865cf2adfced814706d715a7b" +dependencies = [ + "leb128", + "thiserror", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "probo-automerge-native-benchmark" +version = "0.1.0" +dependencies = [ + "automerge", + "hex", + "serde_json", + "sha2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/automerge-benchmark/rust/Cargo.toml b/packages/automerge-benchmark/rust/Cargo.toml new file mode 100644 index 0000000000..e0090eba88 --- /dev/null +++ b/packages/automerge-benchmark/rust/Cargo.toml @@ -0,0 +1,32 @@ +# Copyright (c) 2026 Probo Inc . +# +# 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. + +[package] +name = "probo-automerge-native-benchmark" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[dependencies] +automerge = { version = "=0.10.0", features = ["utf16-indexing"] } +hex = "0.4.3" +serde_json = "1" +sha2 = "0.11.0" diff --git a/packages/automerge-benchmark/rust/src/main.rs b/packages/automerge-benchmark/rust/src/main.rs new file mode 100644 index 0000000000..62b0f7b5e8 --- /dev/null +++ b/packages/automerge-benchmark/rust/src/main.rs @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +use std::collections::HashMap; +use std::fs; +use std::hint::black_box; +use std::time::Instant; + +use automerge::transaction::{CommitOptions, Transactable}; +use automerge::{ActorId, AutoCommit, ObjType, ReadDoc, ScalarValue, Value, ROOT}; +use sha2::{Digest, Sha256}; + +struct BenchmarkWorkload { + run: Box Result<(), String>>, + validate: Box Result>, +} + +fn main() { + let arguments = arguments().unwrap_or_else(|error| fail(&error)); + let workload = arguments + .get("workload") + .cloned() + .unwrap_or_else(|| fail("workload is required")); + let size = parse_usize(&arguments, "size", 0); + let iterations = parse_usize(&arguments, "iterations", 1); + let warmups = parse_usize(&arguments, "warmups", 3); + let fixture = arguments.get("fixture").map(String::as_str); + if iterations == 0 { + fail("iterations must be positive"); + } + + let mut runner = workload_runner(&workload, size, fixture).unwrap_or_else(|error| fail(&error)); + for _ in 0..warmups { + (runner.run)().unwrap_or_else(|error| fail(&error)); + } + + let started_at = Instant::now(); + for _ in 0..iterations { + (runner.run)().unwrap_or_else(|error| fail(&error)); + } + let total_ns = started_at.elapsed().as_nanos(); + let ns_per_op = total_ns / iterations as u128; + let checksum = (runner.validate)().unwrap_or_else(|error| fail(&error)); + + println!( + "{}", + serde_json::json!({ + "workload": workload, + "size": size, + "iterations": iterations, + "totalNs": total_ns, + "nsPerOp": ns_per_op, + "checksum": checksum, + }) + ); +} + +fn workload_runner( + workload: &str, + size: usize, + fixture: Option<&str>, +) -> Result { + match workload { + "create" => Ok(BenchmarkWorkload { + run: Box::new(|| { + let document = new_document(); + black_box(&document); + Ok(()) + }), + validate: Box::new(|| Ok(checksum(b"empty"))), + }), + "map" => Ok(BenchmarkWorkload { + run: Box::new(move || { + let document = map_document(size)?; + black_box(&document); + Ok(()) + }), + validate: Box::new(move || { + let mut document = map_document(size)?; + map_checksum(&mut document, size) + }), + }), + "text" => Ok(BenchmarkWorkload { + run: Box::new(move || { + let document = typed_document(size)?; + black_box(&document); + Ok(()) + }), + validate: Box::new(move || { + let mut document = typed_document(size)?; + text_checksum(&mut document) + }), + }), + "load" => { + let data = fixture_bytes(size, fixture)?; + let validation_data = data.clone(); + Ok(BenchmarkWorkload { + run: Box::new(move || { + let document = AutoCommit::load(&data).map_err(|error| error.to_string())?; + black_box(&document); + Ok(()) + }), + validate: Box::new(move || { + let mut document = + AutoCommit::load(&validation_data).map_err(|error| error.to_string())?; + text_checksum(&mut document) + }), + }) + } + "save" => { + let data = fixture_bytes(size, fixture)?; + let mut document = AutoCommit::load(&data).map_err(|error| error.to_string())?; + Ok(BenchmarkWorkload { + run: Box::new(move || { + black_box(document.save()); + Ok(()) + }), + validate: Box::new(move || { + let mut document = + AutoCommit::load(&data).map_err(|error| error.to_string())?; + text_checksum(&mut document) + }), + }) + } + other => Err(format!("unknown workload {other:?}")), + } +} + +fn map_document(size: usize) -> Result { + let mut document = new_document(); + let values = document + .put_object(&ROOT, "values", ObjType::Map) + .map_err(|error| error.to_string())?; + for index in 0..size { + document + .put(&values, index.to_string(), index as i64) + .map_err(|error| error.to_string())?; + } + commit(&mut document)?; + + Ok(document) +} + +fn typed_document(size: usize) -> Result { + let mut document = new_document(); + let text = document + .put_object(&ROOT, "body", ObjType::Text) + .map_err(|error| error.to_string())?; + for index in 0..size { + document + .splice_text(&text, index, 0, "x") + .map_err(|error| error.to_string())?; + } + commit(&mut document)?; + + Ok(document) +} + +fn fixture_data(size: usize) -> Result, String> { + let mut document = new_document(); + let text = document + .put_object(&ROOT, "body", ObjType::Text) + .map_err(|error| error.to_string())?; + document + .splice_text(&text, 0, 0, &benchmark_text(size)) + .map_err(|error| error.to_string())?; + commit(&mut document)?; + + Ok(document.save()) +} + +fn fixture_bytes(size: usize, file: Option<&str>) -> Result, String> { + match file { + Some(file) => fs::read(file).map_err(|error| error.to_string()), + None => fixture_data(size), + } +} + +fn new_document() -> AutoCommit { + AutoCommit::new().with_actor(ActorId::from((0_u8..16_u8).collect::>())) +} + +fn commit(document: &mut AutoCommit) -> Result<(), String> { + document + .commit_with( + CommitOptions::default() + .with_message("benchmark") + .with_time(0), + ) + .ok_or_else(|| "change contains no operations".to_owned())?; + + Ok(()) +} + +fn benchmark_text(size: usize) -> String { + (0..size) + .map(|index| char::from(b'a' + (index % 26) as u8)) + .collect() +} + +fn map_checksum(document: &mut AutoCommit, size: usize) -> Result { + let (_, values) = document + .get(&ROOT, "values") + .map_err(|error| error.to_string())? + .ok_or_else(|| "values map does not exist".to_owned())?; + let mut normalized = Vec::with_capacity(size * 16); + for index in 0..size { + let (value, _) = document + .get(&values, index.to_string()) + .map_err(|error| error.to_string())? + .ok_or_else(|| format!("map value {index} does not exist"))?; + let Value::Scalar(value) = value else { + return Err(format!("map value {index} is not a scalar")); + }; + let ScalarValue::Int(value) = value.as_ref() else { + return Err(format!("map value {index} is not an integer")); + }; + normalized.extend_from_slice(value.to_string().as_bytes()); + normalized.push(b'\n'); + } + + Ok(checksum(&normalized)) +} + +fn text_checksum(document: &mut AutoCommit) -> Result { + let (_, text) = document + .get(&ROOT, "body") + .map_err(|error| error.to_string())? + .ok_or_else(|| "body text does not exist".to_owned())?; + let value = document.text(&text).map_err(|error| error.to_string())?; + + Ok(checksum(value.as_bytes())) +} + +fn checksum(value: &[u8]) -> String { + hex::encode(Sha256::digest(value)) +} + +fn arguments() -> Result, String> { + let mut values = HashMap::new(); + let mut arguments = std::env::args().skip(1); + while let Some(argument) = arguments.next() { + let name = argument + .strip_prefix("--") + .ok_or_else(|| format!("invalid argument {argument:?}"))?; + let value = arguments + .next() + .ok_or_else(|| format!("missing value for {argument:?}"))?; + values.insert(name.to_owned(), value); + } + Ok(values) +} + +fn parse_usize(arguments: &HashMap, name: &str, fallback: usize) -> usize { + arguments + .get(name) + .map(|value| { + value + .parse() + .unwrap_or_else(|_| fail(&format!("{name} must be an integer"))) + }) + .unwrap_or(fallback) +} + +fn fail(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} diff --git a/packages/automerge-conformance/generate-parity-inventory.mjs b/packages/automerge-conformance/generate-parity-inventory.mjs new file mode 100644 index 0000000000..044d2e7611 --- /dev/null +++ b/packages/automerge-conformance/generate-parity-inventory.mjs @@ -0,0 +1,394 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { execFileSync } from "node:child_process"; + +const rustRoot = requiredArgument("--rust-root"); +const javascriptRoot = requiredArgument("--javascript-root"); +const rustTestList = optionalArgument("--rust-test-list"); +const mappings = loadMappings(optionalArgument("--mappings")); + +verifyGitCheckout( + rustRoot, + "a4f584c86358dd07f83f36708573e1c8d1bd8161", +); +verifyGitCheckout( + javascriptRoot, + "f8b0911dc9d86265dd62934b7dc782571e3a7fcb", +); + +const inventory = { + schemaVersion: 1, + sources: { + rust: { + package: "automerge", + version: "0.10.0", + gitTag: "rust/automerge-0.10.0", + gitCommit: "a4f584c86358dd07f83f36708573e1c8d1bd8161", + crateChecksum: "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633", + }, + javascript: { + package: "@automerge/automerge", + version: "3.4.0", + gitTag: "js/automerge-3.4.0", + gitCommit: "f8b0911dc9d86265dd62934b7dc782571e3a7fcb", + npmIntegrity: "sha512-THmghtTNGGt2xsI0pM3o1i3PM8oZKcYFgOj25FOzW7l6e94SQOivNtCwy6xc0I8hVJsQSSotoBNs+yk/9hM2dg==", + }, + }, + tests: [ + ...rustTests(rustRoot, rustTestList), + ...rustDocumentationTests(rustTestList), + ...javascriptTests(javascriptRoot), + ...javascriptPackagingScenarios(), + ].sort((left, right) => left.id.localeCompare(right.id)), +}; + +process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`); + +function rustTests(root, testList) { + if (!testList) { + throw new Error("--rust-test-list is required for an authoritative inventory"); + } + + const declarations = []; + for (const file of files(root, ".rs")) { + const relative = slashPath(path.relative(root, file)); + const source = fs.readFileSync(file, "utf8"); + const expression + = /#\s*\[\s*test(?:\s*\([^)]*\))?\s*\][\s\S]*?(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z0-9_]+)/g; + for (const match of source.matchAll(expression)) { + const declarationOffset = match.index + match[0].lastIndexOf("fn "); + declarations.push({ + file: relative, + line: lineAt(source, declarationOffset), + name: match[1], + }); + } + } + + return rustRuntimeTestNames(testList).map(runtimeName => { + const name = runtimeName.split("::").at(-1); + const candidates = declarations.filter(candidate => candidate.name === name); + const declaration = selectRustDeclaration(runtimeName, candidates); + const test = testEntry( + "rust", + declaration?.file ?? "runtime-test", + declaration?.line ?? 1, + name, + ); + test.id = `rust:${runtimeName}`; + test.runtimeName = runtimeName; + return test; + }); +} + +function javascriptTests(root) { + const tests = []; + for (const file of files(root, ".ts")) { + const relative = slashPath(path.relative(root, file)); + const source = fs.readFileSync(file, "utf8"); + const executableSource = stripComments(source); + const expression + = /\b(?:it|test)(?:\.(?:only|skip|todo))?\s*\(\s*(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g; + for (const match of executableSource.matchAll(expression)) { + tests.push( + testEntry( + "javascript", + relative, + lineAt(source, match.index), + match[2].replaceAll(/\s+/g, " ").trim(), + ), + ); + } + } + return tests; +} + +function stripComments(source) { + return source + .replaceAll(/\/\*[\s\S]*?\*\//g, comment => + comment.replaceAll(/[^\n]/g, " ") + ) + .replaceAll(/^\s*\/\/.*$/gm, comment => + comment.replaceAll(/[^\n]/g, " ") + ); +} + +function rustDocumentationTests(testList) { + if (!testList) return []; + + const tests = []; + const lines = fs.readFileSync(testList, "utf8").split("\n"); + for (let index = 0; index < lines.length; index++) { + const match = /^(automerge\/.+)\s*:\s*test$/.exec(lines[index]); + if (!match) continue; + + const name = match[1]; + const sourceMatch = /^(.+?)\s+-\s+.+?\s+\(line\s+(\d+)\)$/.exec(name); + tests.push( + testEntry( + "rust-doc", + sourceMatch?.[1] ?? "unknown", + Number(sourceMatch?.[2] ?? index + 1), + name, + ), + ); + } + return tests; +} + +function rustRuntimeTestNames(testList) { + const tests = []; + for (const line of fs.readFileSync(testList, "utf8").split("\n")) { + const match = /^(.+): test$/.exec(line); + if (!match || match[1].startsWith("automerge/")) continue; + tests.push(match[1]); + } + return tests; +} + +function selectRustDeclaration(runtimeName, candidates) { + if (candidates.length <= 1) return candidates[0]; + + const namespace = runtimeName.toLowerCase().replaceAll("_", ""); + return candidates.find(candidate => { + const file = candidate.file + .toLowerCase() + .replaceAll("_", "") + .replaceAll(".rs", ""); + return namespace.includes(file.split("/").at(-1)); + }) ?? candidates[0]; +} + +function javascriptPackagingScenarios() { + const scenarios = [ + "webpack_cjs_fullfat", + "webpack_cjs_slim", + "webpack_esm_fullfat", + "webpack_esm_slim", + "node_cjs_fullfat", + "node_cjs_slim", + "node_esm_fullfat", + "node_esm_slim", + "vite_fullfat:vite_dev_server_fullfat", + "vite_fullfat:vite_build_fullfat", + "vite_slim:vite_dev_server_slim", + "vite_slim:vite_build_slim", + "vite_iife_fullfat", + "workerd", + "workerd_slim", + "iife", + ]; + + return scenarios.map(name => ({ + ...testEntry( + "javascript-packaging", + "packaging_tests/run.mjs", + 360, + name, + ), + classification: "language-specific", + requirement: "language-specific", + rationale: "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior.", + })); +} + +function testEntry(source, file, line, name) { + const languageSpecific = languageSpecificRationale(source, file); + const mapping = mappings.find(candidate => + candidate.source === source + && candidate.file === file + && candidate.name === name + && (candidate.line === undefined || candidate.line === line) + ) ?? builtInCoverage(source, file, name); + const classification = mapping?.classification + ?? (languageSpecific ? "language-specific" : "pending"); + return { + id: `${source}:${file}:${line}:${name}`, + source, + file, + line, + name, + classification, + requirement: classification === "language-specific" + ? "language-specific" + : interoperabilityRequirement(source, file, name), + localTests: mapping?.localTests ?? [], + rationale: mapping?.rationale ?? languageSpecific, + }; +} + +function builtInCoverage(source, file, name) { + if (source !== "rust" || file !== "tests/batch_insert.rs") { + return null; + } + if ( + /(patch|merges_correctly|scalar_fails|with_transaction|multiple_batch|batch_insert_into_existing_map|batch_put_overwrite_with_nested_structure)/.test(name) + ) { + return null; + } + if (name.startsWith("splice_")) { + return { + classification: "covered", + localTests: ["TestDocument_HydrateSpliceMatchesReference"], + rationale: "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + }; + } + + return { + classification: "covered", + localTests: [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback", + ], + rationale: "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + }; +} + +function interoperabilityRequirement(source, file, name) { + if (source === "javascript") { + if ( + [ + "block_test.ts", + "change_time.ts", + "cursors.ts", + "marks.ts", + "text_test.ts", + ].includes(file) + ) { + return "interop-required"; + } + if ( + file === "basic_test.ts" + && /(ImmutableString|RawString|ints and floats|handle text)/i.test(name) + ) { + return "interop-required"; + } + if ( + file === "legacy_tests.ts" + && /(Date objects|strings as initial values)/i.test(name) + ) { + return "interop-required"; + } + + return "api-convenience"; + } + + return "interop-required"; +} + +function languageSpecificRationale(source, file) { + if ( + source === "rust" + && ( + file === "src/sequence_tree.rs" + || file === "src/change_graph.rs" + || file === "src/clock.rs" + || file === "src/exid.rs" + || file === "src/legacy/serde_impls/op.rs" + || file === "src/storage/bundle.rs" + || file === "src/storage/change/change_op_columns.rs" + || file === "src/storage/columns/column_specification.rs" + || file === "src/transaction/inner.rs" + || file.startsWith("src/columnar/") + || file.startsWith("src/op_set2/") + || file.startsWith("src/storage/parse/") + || file.startsWith("src/text_diff/") + ) + ) { + return "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior."; + } + if ( + source === "javascript" + && ( + file === "bundle_test.ts" + || file === "error.ts" + || file === "next_test.ts" + || file === "proxies.ts" + ) + ) { + return "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API."; + } + return ""; +} + +function files(root, extension) { + const result = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const file = path.join(root, entry.name); + if (entry.isDirectory()) { + result.push(...files(file, extension)); + } else if (entry.isFile() && file.endsWith(extension)) { + result.push(file); + } + } + return result; +} + +function requiredArgument(name) { + const index = process.argv.indexOf(name); + if (index < 0 || index + 1 >= process.argv.length) { + throw new Error(`missing required argument ${name}`); + } + return path.resolve(process.argv[index + 1]); +} + +function optionalArgument(name) { + const index = process.argv.indexOf(name); + if (index < 0) return null; + if (index + 1 >= process.argv.length) { + throw new Error(`missing value for argument ${name}`); + } + return path.resolve(process.argv[index + 1]); +} + +function loadMappings(file) { + if (!file) return []; + + const mappings = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Array.isArray(mappings)) { + throw new Error("parity mappings must be an array"); + } + return mappings; +} + +function verifyGitCheckout(root, expectedCommit) { + const actualCommit = execFileSync( + "git", + ["-C", root, "rev-parse", "HEAD"], + { encoding: "utf8" }, + ).trim(); + if (actualCommit !== expectedCommit) { + throw new Error( + `source ${root} is at ${actualCommit}, expected ${expectedCommit}`, + ); + } +} + +function slashPath(value) { + return value.split(path.sep).join("/"); +} + +function lineAt(source, offset) { + return source.slice(0, offset).split("\n").length; +} diff --git a/packages/automerge-conformance/oracle.mjs b/packages/automerge-conformance/oracle.mjs index 849a86c1aa..ad1636c1d3 100644 --- a/packages/automerge-conformance/oracle.mjs +++ b/packages/automerge-conformance/oracle.mjs @@ -30,6 +30,51 @@ for await (const chunk of process.stdin) { const request = JSON.parse(Buffer.concat(chunks).toString("utf8")); switch (request.action) { + case "runScenario": { + const scenario = request.scenario; + let document = Automerge.init({ actor: scenario.actor }); + let pending = []; + for (const operation of scenario.operations) { + if (operation.action !== "commit") { + pending.push(operation); + continue; + } + document = Automerge.change( + document, + { + message: operation.message, + time: operation.timestamp, + }, + draft => { + for (const mutation of pending) { + applyScenarioMutation(draft, mutation); + } + }, + ); + pending = []; + } + if (pending.length !== 0) { + throw new Error("scenario contains uncommitted operations"); + } + process.stdout.write( + JSON.stringify({ + data: normalize(document), + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "inspectScenario": { + const document = Automerge.load(Buffer.from(request.document, "base64")); + process.stdout.write( + JSON.stringify({ + data: normalize(document), + heads: Automerge.getHeads(document), + }), + ); + break; + } case "create": { let document = Automerge.init({ actor: request.actor }); document = Automerge.change( @@ -100,6 +145,21 @@ switch (request.action) { ); break; } + case "createEmptyChange": { + let document = Automerge.init({ actor: request.actor }); + document = Automerge.emptyChange(document, { + message: request.message, + time: request.timestamp, + }); + const changes = Automerge.getAllChanges(document); + process.stdout.write( + JSON.stringify({ + change: Buffer.from(changes[0]).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } case "createConcurrentChanges": { const base = Automerge.from({ body: "" }, { actor: request.actor }); let left = Automerge.load(Automerge.save(base), { actor: request.actorB }); @@ -150,6 +210,7 @@ switch (request.action) { process.stdout.write( JSON.stringify({ body: document.title, + data: normalize(decoded), message: decoded.message, heads: [decoded.hash], }), @@ -189,6 +250,77 @@ switch (request.action) { ); break; } + case "createBoundaryMarks": { + let document = Automerge.from({ body: "ABC" }, { actor: request.actor }); + document = Automerge.change(document, draft => { + Automerge.mark( + draft, + ["body"], + { start: 0, end: 1, expand: "none" }, + "strong", + true, + ); + Automerge.mark( + draft, + ["body"], + { start: 1, end: 3, expand: "both" }, + "em", + true, + ); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "createSplitMarks": { + let document = Automerge.from({ body: "ABCD" }, { actor: request.actor }); + document = Automerge.change(document, draft => { + Automerge.mark( + draft, + ["body"], + { start: 0, end: 4, expand: "both" }, + "strong", + true, + ); + Automerge.unmark( + draft, + ["body"], + { start: 1, end: 3, expand: "none" }, + "strong", + ); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "createUnicodeMarks": { + let document = Automerge.from({ body: "πŸ˜€πŸ˜€" }, { actor: request.actor }); + document = Automerge.change(document, draft => { + Automerge.mark( + draft, + ["body"], + { start: 2, end: 4, expand: "none" }, + "strong", + true, + ); + Automerge.splice(draft, ["body"], 0, 0, "πŸ™ƒ"); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } case "createTableRichText": { let document = Automerge.from({ body: "" }, { actor: request.actor }); document = Automerge.change(document, draft => { @@ -224,6 +356,43 @@ switch (request.action) { ); break; } + case "createDataModel": { + let document = Automerge.init({ actor: request.actor }); + document = Automerge.change(document, draft => { + draft.values = { + string: "value", + integer: -42, + float: 3.25, + true: true, + false: false, + null: null, + bytes: new Uint8Array([0, 1, 254, 255]), + timestamp: new Date("2026-08-08T00:00:00.000Z"), + counter: new Automerge.Counter(5), + }; + draft.list = ["first", 2, true, null, new Date("2026-08-08T00:00:00.000Z")]; + draft.text = ""; + Automerge.splice(draft, ["text"], 0, 0, "AπŸ˜€B"); + }); + process.stdout.write( + JSON.stringify({ + data: normalize(document), + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "inspectDataModel": { + const document = Automerge.load(Buffer.from(request.document, "base64")); + process.stdout.write( + JSON.stringify({ + data: normalize(document), + heads: Automerge.getHeads(document), + }), + ); + break; + } case "inspect": { const document = Automerge.load(Buffer.from(request.document, "base64")); process.stdout.write( @@ -234,6 +403,57 @@ switch (request.action) { ); break; } + case "inspectChanges": { + const document = Automerge.load(Buffer.from(request.document, "base64")); + process.stdout.write( + JSON.stringify({ + changes: Automerge.getAllChanges(document).map(change => + Buffer.from(change).toString("base64") + ), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "createTimestamps": { + let document = Automerge.init({ actor: request.actor }); + const when = new Date("2026-08-08T12:34:56.000Z"); + document = Automerge.change( + document, + { message: request.message, time: request.timestamp }, + draft => { + draft.when = when; + draft.list = [when]; + }, + ); + process.stdout.write( + JSON.stringify({ + data: { iso: when.toISOString() }, + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "readTimestamps": { + const document = Automerge.load(Buffer.from(request.document, "base64")); + process.stdout.write( + JSON.stringify({ + data: { + whenIsDate: document.when instanceof Date, + whenISO: + document.when instanceof Date ? document.when.toISOString() : null, + listIsDate: document.list[0] instanceof Date, + listISO: + document.list[0] instanceof Date + ? document.list[0].toISOString() + : null, + }, + heads: Automerge.getHeads(document), + }), + ); + break; + } default: throw new Error(`unsupported action: ${request.action}`); } @@ -249,3 +469,120 @@ function tableBlock(type, parents, attrs = {}) { }, }; } + +function normalize(value) { + if (typeof value === "bigint") { + return { type: "bigint", value: value.toString() }; + } + if (value instanceof Uint8Array) { + return { type: "bytes", value: Buffer.from(value).toString("base64") }; + } + if (value instanceof Date) { + return { type: "timestamp", value: value.toISOString() }; + } + if (value instanceof Automerge.Counter) { + return { type: "counter", value: value.value }; + } + if (Automerge.isImmutableString(value)) { + return value.val; + } + if (Array.isArray(value)) { + return value.map(normalize); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, normalize(value[key])]), + ); + } + return value; +} + +function applyScenarioMutation(document, operation) { + const parent = valueAtPath(document, operation.path); + switch (operation.action) { + case "createObject": + parent[operation.key] = scenarioObject(operation.objectType); + return; + case "putScalar": + parent[operation.key] = scenarioScalar(operation.scalar); + return; + case "insertScalar": + parent.splice(operation.index, 0, scenarioScalar(operation.scalar)); + return; + case "putScalarAt": + parent[operation.index] = scenarioScalar(operation.scalar); + return; + case "deleteIndex": + parent.splice(operation.index, 1); + return; + case "createText": + parent[operation.key] = ""; + return; + case "spliceText": + Automerge.splice( + document, + operation.path, + operation.index, + operation.deleteCount, + operation.text, + ); + return; + case "increment": + parent[operation.key].increment(operation.delta); + return; + default: + throw new Error(`unsupported scenario mutation: ${operation.action}`); + } +} + +function valueAtPath(document, path) { + let value = document; + for (const property of path) { + value = value[property]; + } + return value; +} + +function scenarioObject(type) { + switch (type) { + case "map": + return {}; + case "list": + return []; + default: + throw new Error(`unsupported scenario object type: ${type}`); + } +} + +function scenarioScalar(scalar) { + switch (scalar.type) { + case "null": + return null; + case "boolean": + return scalar.bool; + case "uint": + return new Automerge.Uint(scalar.uint); + case "int": + return new Automerge.Int(scalar.int); + case "float64": + return new Automerge.Float64(floatFromBits(scalar.floatBits)); + case "string": + return new Automerge.ImmutableString(scalar.string); + case "bytes": + return Uint8Array.from(Buffer.from(scalar.bytes, "hex")); + case "timestamp": + return new Date(scalar.int); + case "counter": + return new Automerge.Counter(scalar.int); + default: + throw new Error(`unsupported scenario scalar type: ${scalar.type}`); + } +} + +function floatFromBits(value) { + const data = new DataView(new ArrayBuffer(8)); + data.setBigUint64(0, BigInt(value), false); + return data.getFloat64(0, false); +} diff --git a/packages/automerge-conformance/package.json b/packages/automerge-conformance/package.json index 910127c77c..f28e317a8b 100644 --- a/packages/automerge-conformance/package.json +++ b/packages/automerge-conformance/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "scripts": { - "check": "node --check oracle.mjs" + "check": "node --check oracle.mjs && node --check generate-parity-inventory.mjs" }, "dependencies": { "@automerge/automerge": "^3.4.0" diff --git a/packages/automerge-conformance/parity-mappings.json b/packages/automerge-conformance/parity-mappings.json new file mode 100644 index 0000000000..547f2026f8 --- /dev/null +++ b/packages/automerge-conformance/parity-mappings.json @@ -0,0 +1,3280 @@ +[ + { + "source": "rust", + "file": "src/sync.rs", + "name": "encode_decode_empty_message", + "classification": "covered", + "localTests": [ + "TestSyncMessageEncodeDecodeEmptyV2" + ], + "rationale": "An empty V2 sync message encodes to the reference wire bytes and parses back without error, keeping the native V2 codec byte-compatible." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "should_handle_false_positive_head", + "classification": "covered", + "localTests": [ + "TestRustSync_ShouldHandleFalsePositiveHead" + ], + "rationale": "A concurrent head that is a Bloom false positive (located with the reference engine's real Bloom filter) still converges under V2 sync on both engines." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "should_handle_chains_of_false_positives", + "classification": "covered", + "localTests": [ + "TestRustSync_ShouldHandleChainsOfFalsePositives" + ], + "rationale": "Two chained changes that are both Bloom false positives (located with the reference engine's real Bloom filter) still converge under V2 sync on both engines." + }, + { + "source": "rust", + "file": "src/automerge/tests.rs", + "name": "observe_counter_change_application", + "classification": "covered", + "localTests": [ + "TestRustAutomerge_ObserveCounterChangeApplication" + ], + "rationale": "Applying a change that creates and increments a counter yields identical incremental patches on both engines; the pinned reference collapses the sequence into a single put through diff_incremental and native matches it exactly." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_compressed_doc_cols", + "classification": "covered", + "localTests": [ + "TestRustTest_CompressedDocCols" + ], + "rationale": "A large document saves smaller with DEFLATE compression than without on both engines, and the compressed save loads back to the same 200-element list." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "can_isolate", + "classification": "covered", + "localTests": [ + "TestRustTest_CanIsolate" + ], + "rationale": "Isolating to a historical frontier pins reads and branches writes with derived concurrency actors; merges during isolation stay hidden until integrate; repeated isolate/integrate cycles reproduce the reference text and values on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "can_transaction_at", + "classification": "covered", + "localTests": [ + "TestRustTest_CanTransactionAt" + ], + "rationale": "Writing at a pinned historical frontier via isolate/integrate branches from those heads and merges with concurrent writes, reproducing the reference text and values on both engines." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "incorrect_patches_produced_when_isolating_and_integrating", + "classification": "covered", + "localTests": [ + "TestRustText_IncorrectPatchesProducedWhenIsolatingAndIntegrating" + ], + "rationale": "An incremental diff across an isolate/integrate cycle with a conflicting object put resets to the isolate frontier and rebuilds, emitting deletes, conflicting puts, and a splice only for each winning object, matching the reference on both engines." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "update_text_change_at", + "classification": "covered", + "localTests": [ + "TestRustText_UpdateTextChangeAt" + ], + "rationale": "An isolated update_text branches from the initial heads and integrates alongside the concurrent update, yielding the reference text on both engines." + }, + { + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "name": "save_orphaned_changes", + "classification": "covered", + "localTests": [ + "TestRustOrphans_SaveOrphanedChanges" + ], + "rationale": "A retained orphan change survives a save/load round trip on both engines, so applying the missing dependency afterwards resolves it to the final value." + }, + { + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "name": "discard_orphans", + "classification": "covered", + "localTests": [ + "TestRustOrphans_DiscardOrphans" + ], + "rationale": "Saving with retain_orphans disabled drops the orphan change on both engines, so a reload plus the missing dependency yields only the applicable value." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "marks_are_okay", + "classification": "covered", + "localTests": [ + "TestRustText_MarksAreOkay" + ], + "rationale": "Randomized insert/delete/split-block/mark sequences keep spans consolidated and reproduce the accumulated text on both engines, matching the upstream property invariants." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "insertions_after_noexpand_spans_are_not_marked", + "classification": "covered", + "localTests": [ + "TestRustText_InsertionsAfterNoexpandSpans" + ], + "rationale": "Text appended after a block with no expanding mark in scope is reported by a diff as an unmarked splice." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_load_changes", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_LoadChanges" + ], + "rationale": "Loading a stored document and materializing current state yields the summed counter put, matching the reference." + }, + { + "source": "javascript", + "file": "marks.ts", + "name": "should allow marks that can be seen in patches", + "classification": "covered", + "localTests": [ + "TestJSMarks_MarksSeenInPatches" + ], + "rationale": "Mark and unmark operations are reported as Mark patches, and text spliced into an expanding mark carries it, matching the reference." + }, + { + "source": "javascript", + "file": "marks.ts", + "name": "patches properly report marks on end of expand true", + "classification": "covered", + "localTests": [ + "TestRustText_ExpandMarksAreReportedInPatches" + ], + "rationale": "Mark and unmark operations are reported as Mark patches, and text spliced into an expanding mark carries it, matching the reference." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_change_encoding_expanded_change_round_trip", + "classification": "covered", + "localTests": [ + "TestChangeEncodingExpandedRoundTrip" + ], + "rationale": "A change decoded from its canonical bytes re-encodes to exactly those bytes, validating change wire fidelity." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc", + "classification": "covered", + "localTests": [ + "TestRustSync_FirstMessageNoHeadsSendsWholeDoc" + ], + "rationale": "An empty peer receives the entire document in the first sync response and converges after a single exchange." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "reproduce_clock_cache_bug", + "classification": "covered", + "localTests": [ + "TestRust_ReproduceClockCacheBug" + ], + "rationale": "Merging many branches by distinct actors leaves no change outside the merged frontier, exercising vector-clock ancestry." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "getCursor should respect heads", + "classification": "covered", + "localTests": [ + "TestJSCursors_GetCursorRespectsHeads" + ], + "rationale": "Cursors created against a historical view resolve to the same positions on both engines." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should be able to pass a doc to from() to make a shallow copy", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript Automerge.from(doc) shallow-copy binding helper; Go clones via save/load." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "patch callbacks inform where they came from", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript patchCallback PatchSource metadata; a binding-specific callback channel with no wire or state interop meaning." + }, + { + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "name": "test_strings_in_maps_are_converted_to_text", + "classification": "covered", + "localTests": [ + "TestRustConvert_StringsInMapsAreConvertedToText" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference." + }, + { + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "name": "test_strings_in_lists_are_converted_to_text", + "classification": "covered", + "localTests": [ + "TestRustConvert_StringsInListsAreConvertedToText" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference." + }, + { + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "name": "test_does_not_add_size_when_strings_are_not_converted", + "classification": "covered", + "localTests": [ + "TestRustConvert_DoesNotAddSizeWhenStringsAreNotConverted" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference." + }, + { + "source": "rust", + "file": "src/iter/list_range.rs", + "name": "list_range_bounds", + "classification": "covered", + "localTests": [ + "TestRustListRange_Bounds" + ], + "rationale": "List values and per-element conflict flags match the reference." + }, + { + "source": "rust", + "file": "src/iter/list_range.rs", + "name": "list_range_conflict", + "classification": "covered", + "localTests": [ + "TestRustListRange_Conflict" + ], + "rationale": "List values and per-element conflict flags match the reference." + }, + { + "source": "rust", + "file": "tests/test_mark_patches.rs", + "name": "mark_patches_at_end_of_text", + "classification": "covered", + "localTests": [ + "TestRustMarkPatches_AtEndOfText" + ], + "rationale": "A mark loaded incrementally into another document produces a single Mark patch through the diff cursor." + }, + { + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "name": "rollback_discards_ops", + "classification": "covered", + "localTests": [ + "TestRustTransaction_RollbackDiscardsOps" + ], + "rationale": "Transaction rollback discards uncommitted operations and reports the discarded count, matching the reference." + }, + { + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "name": "rollback_undoes_writes", + "classification": "covered", + "localTests": [ + "TestRustTransaction_RollbackUndoesWrites" + ], + "rationale": "Transaction rollback discards uncommitted operations and reports the discarded count, matching the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "should update marks", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/should_update_marks" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "allows configuring the default expand value of created marks", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/configuring_default_expand" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "should allow overriding the default expand on a per mark basis", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/override_default_expand_per_mark" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "should allow omitting any part of the update spans config", + "classification": "covered", + "localTests": [ + "TestJSBlock_OmittingConfigParts" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "updates the document even if the only change was to a block attribute", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/updates_document_on_block_attribute_change" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "should show historical marks", + "classification": "covered", + "localTests": [ + "TestJSBlock_ShowHistoricalMarks" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "can allow small values in block attributes", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/small_values_in_block_attributes" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "allows updating all blocks at once", + "classification": "covered", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/allows_updating_all_blocks_at_once" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "emits insert patches with ImmutableString for attribute updatese", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) patch value wrapper; a binding-specific scalar type." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "when loading blocks", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) block attribute wrapper round-trip; a binding-specific scalar type." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "when loading spans", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) block attribute wrapper round-trip; a binding-specific scalar type." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should support implicit and explicit deletion", + "classification": "covered", + "localTests": [ + "TestJSText_ImplicitAndExplicitDeletion" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should handle text and other ops in the same change", + "classification": "covered", + "localTests": [ + "TestJSText_TextAndOtherOpsSameChange" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should initialize text in Automerge.from()", + "classification": "covered", + "localTests": [ + "TestJSText_InitializeTextInFrom" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should encode the initial value as a change", + "classification": "covered", + "localTests": [ + "TestJSText_InitializeTextInFrom" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should allow splicing into text in arrays", + "classification": "covered", + "localTests": [ + "TestJSText_SplicingIntoArrays" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should calculate a diff when updating text", + "classification": "covered", + "localTests": [ + "TestRustText_SimpleUpdateText" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should serialize to JSON as a simple string", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript JSON.stringify serialization shape of the document proxy; no wire or state interop meaning." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should allow modification after an object is assigned to a document", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript mutable-proxy assignment semantics inside a change callback; Go mutates through explicit typed methods." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should not allow modification outside of a change callback", + "classification": "language-specific", + "localTests": [], + "rationale": "JavaScript change-callback guard on the mutable proxy; Go has no ambient-mutation API to guard." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "marks_on_spans_respect_heads", + "classification": "covered", + "localTests": [ + "TestRustBlock_MarksOnSpansRespectHeads" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "diff_emits_block_updates", + "classification": "covered", + "localTests": [ + "TestRustBlock_DiffEmitsBlockUpdates" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "merge_produces_block_insertion_diffs", + "classification": "covered", + "localTests": [ + "TestRustBlock_MergeProducesBlockInsertionDiffs" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_insert_generates_patches", + "classification": "covered", + "localTests": [ + "TestRustBatchInsert_GeneratesPatches" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_insert_text_generates_splice_patch", + "classification": "covered", + "localTests": [ + "TestRustBatchInsert_TextGeneratesSplicePatch" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_init_map_generates_patches", + "classification": "covered", + "localTests": [ + "TestRustBatchInit_MapGeneratesPatches" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_blocks_change_block_properties", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_blocks_change_block_properties" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_blocks_updates_text", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_text" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_blocks_updates_marks", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_blocks_updates_text_and_blocks_at_once", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_text_and_blocks_at_once" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_spans_delete_attribute", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_spans_delete_attribute" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_spans_diffs_marks", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_spans_diffs_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_spans_uses_expand_config", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_spans_uses_expand_config" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "update_blocks_noop", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans_Noop" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_spans_across_block", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/mark_spans_across_block" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_ends_at_block_boundary", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/mark_ends_at_block_boundary" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "block_properties_change_with_marks", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/block_properties_change_with_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "block_with_marked_content", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/block_with_marked_content" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "update_spans_with_only_blocks", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/update_spans_with_only_blocks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "marks_survive_block_updates", + "classification": "covered", + "localTests": [ + "TestRustBlockSpans/marks_survive_block_updates" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "adjacent_marks_merge", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/adjacent_marks_merge" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "adjacent_marks_stay_separate", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/adjacent_marks_stay_separate" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "alternating_mark_changes", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks_Alternating" + ], + "rationale": "Repeatedly adding and removing a mark converges on the final span set." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "complex_unicode_text", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/complex_unicode_text" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "empty_spans_between_marks", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/empty_spans_between_marks" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "idempotent_update_spans", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks_Idempotent" + ], + "rationale": "Repeating the same update_spans call produces no additional changes." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "many_marks_on_same_text", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/many_marks_on_same_text" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_contracts", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_contracts" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_expands", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_expands" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_on_empty_string", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_on_empty_string" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_on_whitespace", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_on_whitespace" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_shifts_position", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_shifts_position" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_value_changes_color", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_value_changes_color" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_value_changes_link_url", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_value_changes_link_url" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_value_type_changes", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/mark_value_type_changes" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "marks_on_combining_characters", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/marks_on_combining_characters" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "marks_with_different_values_same_name", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/marks_with_different_values_same_name" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "marks_with_expand_none_at_boundaries", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/marks_with_expand_none_at_boundaries" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "multiple_marks_different_expand_behaviors", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/multiple_marks_different_expand_behaviors" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "nested_marks", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/nested_marks" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "overlapping_marks_add_third_mark", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_add_third_mark" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "overlapping_marks_change_boundaries", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_change_boundaries" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "overlapping_marks_remove_one_keep_other", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_remove_one_keep_other" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "removing_all_text_from_marked_span", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/removing_all_text_from_marked_span" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes", + "classification": "covered", + "localTests": [ + "TestRustDiffMarks/update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches", + "classification": "covered", + "localTests": [ + "TestRustText_CrossPageMarksNotDoubleCounted" + ], + "rationale": "A non-expanding mark crossing an operation-tree page boundary does not leak onto text appended after repeated block insertions." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "expand_marks_are_reported_in_patches", + "classification": "covered", + "localTests": [ + "TestRustText_ExpandMarksAreReportedInPatches" + ], + "rationale": "A both-expanding mark includes text inserted at either boundary and both incremental splice patches carry it." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "test_remote_patches_for_marks_with_expand_after", + "classification": "covered", + "localTests": [ + "TestRustText_RemotePatchesForExpandAfter" + ], + "rationale": "A remote insertion at an after-expanding boundary produces the same marked splice patch as the local edit." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "marks", + "classification": "covered", + "localTests": [ + "TestRustMarks_ExpansionAndUnmark" + ], + "rationale": "A both-expanding mark grows at its end, unmark removes only the original prefix, and prepended text remains unmarked." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "patch_put_seq", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_PatchPutSeq" + ], + "rationale": "An in-place text put reported through the incremental diff cursor produces a PutSeq patch at a UTF-16 index." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "patch_mark", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_PatchMark" + ], + "rationale": "A mark produces a Mark patch whose start and end are UTF-16 code units, and the diff carries added, removed (null-valued), and changed marks." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "patch_insert", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_PatchInsert" + ], + "rationale": "An insert on text produces a SpliceText patch addressed by UTF-16 code units." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "patch_splice_text", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_PatchSpliceText" + ], + "rationale": "A splice on text produces a SpliceText patch addressed by UTF-16 code units." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "patch_delete", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_PatchDelete" + ], + "rationale": "A delete on text produces a DeleteSeq patch at a UTF-16 index with length one." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "length", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_Length" + ], + "rationale": "ReadDoc::length reports UTF-16 code units for text; a family emoji counts as 11 units." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "get", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_Get" + ], + "rationale": "ReadDoc::get resolves a UTF-16 index across a multi-code-point grapheme to the correct element." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "put", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_Put" + ], + "rationale": "Transactable::put replaces the element at a UTF-16 index, materializing the winning value for text." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "delete", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_Delete" + ], + "rationale": "Transactable::delete removes the element addressed by a UTF-16 index." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "split_block", + "classification": "covered", + "localTests": [ + "TestRustTextEncoding_SplitBlock" + ], + "rationale": "Transactable::split_block splits text at a UTF-16 index between grapheme clusters." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "incremental_splice_patches_include_marks", + "classification": "covered", + "localTests": [ + "TestRustText_IncrementalSplicePatchesIncludeMarks" + ], + "rationale": "Text spliced inside an expanding mark is reported as a splice_text patch carrying that mark, with no separate mark patch for the range growth." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions", + "classification": "covered", + "localTests": [ + "TestRustText_NoexpandMarksAtEndOfText" + ], + "rationale": "Text appended after a non-expanding mark does not inherit it, so the splice patch carries no marks." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "local_patches_created_for_marks", + "classification": "covered", + "localTests": [ + "TestRustText_LocalPatchesCreatedForMarks" + ], + "rationale": "Materializing marked text through the diff cursor splits it into one splice_text patch per mark run, each carrying the active marks." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "simple_update_text", + "classification": "covered", + "localTests": [ + "TestRustText_SimpleUpdateText" + ], + "rationale": "update_text computes a minimal grapheme diff so concurrent edits to disjoint words merge into a combined document, matching the reference change history." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "update_text_big_ole_graphemes", + "classification": "covered", + "localTests": [ + "TestRustText_UpdateTextBigOleGraphemes" + ], + "rationale": "update_text treats emoji ZWJ sequences as single grapheme clusters so concurrent family swaps merge side by side, matching the reference change history." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "in_flight_logic_should_not_sabotage_concurrent_changes", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_ReferenceEditsWhileMessageInFlight" + ], + "rationale": "A reference peer commits another change while its previous message is awaiting acknowledgement." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "triangle_changes_arrive_via_two_paths", + "classification": "covered", + "localTests": [ + "TestSyncState_ThreePeerRelayConvergesWithReference" + ], + "rationale": "Three peers relay concurrent changes through native and reference engines and converge to identical heads." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "generate_sync_message_twice_does_nothing", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_SyncWaitsForPeerResponse" + ], + "rationale": "A second generation attempt while a message is in flight returns no message." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "handle_repeated_out_of_order_changes", + "classification": "covered", + "localTests": [ + "TestDocument_AppliesDependentChangesInAnyOrder", + "TestBackendMerge_AppliesReversedDependentChanges" + ], + "rationale": "Dependent changes are applied child-first in one batch, separately, and repeatedly." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "splicing_into_multibyte_characters", + "classification": "covered", + "localTests": [ + "TestText_SpliceUsesUTF16Offsets", + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "UTF-16 splices include surrogate-pair insertion and deletion with reference parity." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character", + "classification": "covered", + "localTests": [ + "TestRustRichText_DeletingInMiddleOfMultibyteChar" + ], + "rationale": "Splice starts and deletion ends inside UTF-16 surrogate pairs are advanced to the following character boundary, with every intermediate text value compared directly against Rust." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "splice_text", + "classification": "covered", + "localTests": [ + "TestText_SpliceUsesUTF16Offsets", + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random and deterministic UTF-16 splices are checked after every operation against Rust." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "cursors", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_UTF16CursorBoundariesMatchReference", + "TestPureGoDocument_DeletedCursorMatchesReference" + ], + "rationale": "Live and deleted cursor targets are compared with the UTF-16 Rust reference." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "mark", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_MarkAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently author the same mark and cross-load the resulting marked text." + }, + { + "source": "rust", + "file": "tests/text_encoding.rs", + "name": "unmark", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_MarkAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently unmark an interior UTF-16 range and cross-load the split spans." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_with_empty_commits", + "classification": "covered", + "localTests": [ + "TestDocument_EmptyCommitTimeParity", + "TestDocument_EmptyCommitChangesSince" + ], + "rationale": "Sequences of empty changes save, load in Rust, preserve metadata, and retain their head hashes." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "get_changes_with_hash_of_empty_change_produces_correct_result", + "classification": "covered", + "localTests": [ + "TestDocument_EmptyCommitChangesSince" + ], + "rationale": "ChangesSince returns the empty change from no heads and no changes when given its hash." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_and_restore_empty", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_EmptySnapshotLoadsInReference" + ], + "rationale": "An empty native document saves and loads in Rust with no heads." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should allow a subset of changes to be sent", + "classification": "covered", + "localTests": [ + "TestBackendSync_SendsOnlyChangesSinceRemoteHeads" + ], + "rationale": "After acknowledging the initial frontier, only the subsequent change is sent." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should not generate messages once synced", + "classification": "covered", + "localTests": [ + "TestSyncState_ExchangesConcurrentChanges", + "TestPureGoDocument_SyncWaitsForPeerResponse" + ], + "rationale": "The shared sync helper requires bounded quiescence and generation blocks while awaiting acknowledgement." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should allow simultaneous messages during synchronization", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_ReferenceEditsWhileMessageInFlight" + ], + "rationale": "Native and official peers exchange edits created while another message is in flight." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should work with prior sync state", + "classification": "covered", + "localTests": [ + "TestSyncState_ResumesPersistedSession", + "TestSyncState_ResendsInFlightMessageAfterRestore" + ], + "rationale": "Quiescent and in-flight serialized sync sessions resume and converge." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should handle changes concurrent to the last sync heads", + "classification": "covered", + "localTests": [ + "TestSyncState_ExchangesConcurrentChanges", + "TestPureGoDocument_RandomConcurrentSyncParity" + ], + "rationale": "Both peers edit from the last shared frontier before each randomized sync round." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should sync three nodes", + "classification": "covered", + "localTests": [ + "TestSyncState_ThreePeerRelayConvergesWithReference" + ], + "rationale": "Three native/reference peers relay and converge concurrent changes." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should support unicode when creating text", + "classification": "covered", + "localTests": [ + "TestConformance_JavaScriptLoadsGoDocument", + "TestConformance_GoLoadsJavaScriptDocument" + ], + "rationale": "Emoji-bearing text documents load in both JavaScript-to-Go and Go-to-JavaScript directions." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should handle multi character grapheme clusters", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_RandomTextParity", + "TestPureGoDocument_UTF16CursorBoundariesMatchReference" + ], + "rationale": "Random UTF-16 operations include non-ASCII and surrogate pairs with Rust parity." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should support insertion", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random insertion positions are checked against the official reference after every operation." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should support deletion", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random UTF-16 deletions are checked against the official reference after every operation." + }, + { + "source": "javascript", + "file": "text_test.ts", + "name": "should handle concurrent insertion", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_RandomConcurrentSyncParity" + ], + "rationale": "Both engines insert concurrently at randomized positions and compare values and heads." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "getCursorPosition should work", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorMatchesReference" + ], + "rationale": "Cursor bytes and resolved positions are compared directly with Rust." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should use javascript string indices", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_UTF16CursorBoundariesMatchReference" + ], + "rationale": "Cursor behavior at every relevant UTF-16 boundary of an emoji string matches Rust's JavaScript indexing mode." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should allow for usage of start/end cursors", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Start and end cursor bytes and resolved UTF-16 positions match Rust." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should allow for usage of move before/after", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Before/after movement bytes and deleted-target resolution are compared directly with Rust." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should convert negative indices into a start cursor", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Negative Go cursor indices clamp to the canonical start cursor and match Rust." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should convert indices >= string length into an end cursor", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Indices at and beyond UTF-16 text length clamp to the canonical end cursor." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "can use cursors in common text operations", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "A stable cursor is resolved and used for replacement in native and Rust texts." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "can use cursors in splice calls", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Cursor-addressed UTF-16 splice operations produce identical native and Rust text." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle basic set and read on root object", + "classification": "covered", + "localTests": [ + "TestDocument_StringParity", + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "Root scalar writes and reads execute against native and Rust engines and cross-load in both directions." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle overwrites to values", + "classification": "covered", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Multiple assignments in one change resolve to the final value in both engines." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "it should be able to handle ints and floats at their limits", + "classification": "covered", + "localTests": [ + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "The scalar differential includes maximum unsigned integers, minimum signed integers, infinity, and NaN with bit-exact float comparison." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "negative_64", + "classification": "covered", + "localTests": [ + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "The minimum signed 64-bit scalar round-trips through native, Rust, and native-to-Rust loading." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_and_reload_create_object", + "classification": "covered", + "localTests": [ + "TestDocument_LoadedObjectRemainsEditable" + ], + "rationale": "A list created without children is saved, loaded, mutated under a new actor, saved again, and read by Rust." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle set with object value", + "classification": "covered", + "localTests": [ + "TestDocument_NestedMapsAndListsMatchReference" + ], + "rationale": "Nested map values are authored independently by Go and Rust and cross-loaded." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle simple list creation", + "classification": "covered", + "localTests": [ + "TestDocument_LoadedObjectRemainsEditable" + ], + "rationale": "An empty list is created, committed, saved, loaded, and resolved as the same object." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle simple lists", + "classification": "covered", + "localTests": [ + "TestDocument_NestedMapsAndListsMatchReference", + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "List insertion, indexed reads, replacement, deletion, change encoding, and cross-engine application are exercised." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_local_inc_in_map", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Map counters are incremented repeatedly with positive and negative deltas in native and Rust engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_and_load_incremented_counter", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Incremented counters are saved by Go, loaded by Rust, and compared after concurrent merging." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "add_concurrent_increments_of_same_property", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Two actors increment the same counter concurrently; Go merges to the summed value and Rust verifies it." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "increment_non_counter_map", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Both native and Rust reject incrementing an integer map property." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "increment_non_counter_list", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Both native and Rust reject incrementing an integer list element." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should add concurrent increments of the same property", + "classification": "covered", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Concurrent increments merge additively and are loaded by the JavaScript-compatible Rust engine." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should allow deleting counters from maps", + "classification": "covered", + "localTests": [ + "TestDocument_CounterDeletionMatchesReference" + ], + "rationale": "Native and Rust both delete a map counter after it is committed." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should initially be an empty map", + "classification": "covered", + "localTests": [ + "TestDocument_MapKeysMatchReference" + ], + "rationale": "Fresh native and Rust root maps expose no keys." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should save and load maps with @ symbols in the keys", + "classification": "covered", + "localTests": [ + "TestDocument_MapKeysMatchReference" + ], + "rationale": "Empty, lexical, and @-containing keys persist through native and Rust save/load." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should allow a document to be cloned", + "classification": "covered", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Native and Rust forks preserve base history while accepting independent changes." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should allow passing an actorId when instantiating from an existing object", + "classification": "covered", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Fork assigns the requested actor before creating and merging independent changes." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "missing_actors_when_docs_are_forked", + "classification": "covered", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Forked histories with a new actor save, merge, and resolve in both native and Rust engines." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "have many list methods", + "classification": "covered", + "localTests": [ + "TestDocument_RandomListParity" + ], + "rationale": "Randomized indexed insertion, replacement, and deletion execute identically and produce identical change hashes in Go and Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "list_deletion", + "classification": "covered", + "localTests": [ + "TestDocument_RandomListParity" + ], + "rationale": "Random list histories repeatedly delete first, middle, and last elements and compare every remaining value with Rust." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle basic sets over many changes", + "classification": "covered", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "One thousand randomized nested-map changes produce identical values and change hashes in Go and Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "delete_only_change", + "classification": "covered", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "Random histories commit standalone map deletions and compare their hashes and resulting absence with Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "allows_empty_keys_in_mappings", + "classification": "covered", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "The randomized map key set explicitly includes the empty string for writes, reads, overwrites, and deletion." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_merging_test_conflicts_then_saving_and_loading", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "Concurrent map assignments are merged, all conflicts and the winner are checked, then the merged document is loaded by Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "repeated_map_assignment_which_resolves_conflict_not_ignored", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "A merged two-value conflict is overwritten and verified to contain exactly the resolving value." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_overwriting_a_conflict", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "The winner and both conflicts are checked before a new assignment clears the conflict." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "no_conflict_on_repeated_assignment", + "classification": "covered", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Repeated assignments in one change produce exactly one visible value in Go and Rust." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "handle text", + "classification": "covered", + "localTests": [ + "TestInteropScenario_CoreDataModel" + ], + "rationale": "The same text creation, UTF-16 splice, save, cross-load, and materialization scenario runs independently in Go, Rust, and JavaScript." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should generate a hash", + "classification": "covered", + "localTests": [ + "TestInteropScenario_CoreDataModel", + "TestConformance_JavaScriptPreservesGoChanges" + ], + "rationale": "All engines generate heads, and transferred Go changes retain their original hash through JavaScript." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 7, + "name": "should default to current timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "CommitNow records a current Unix-seconds timestamp in native and Rust changes." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 18, + "name": "should allow user provided timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "An explicit timestamp is decoded unchanged from native and Rust changes." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 27, + "name": "should allow no timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "A zero time records the protocol no-timestamp value in native and Rust changes." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 37, + "name": "should default to current timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_EmptyCommitTimeParity" + ], + "rationale": "EmptyCommitNow records a current Unix-seconds timestamp in native and Rust changes." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 48, + "name": "should allow user provided timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_EmptyCommitTimeParity" + ], + "rationale": "An explicit timestamp is preserved on empty native and Rust changes." + }, + { + "source": "javascript", + "file": "change_time.ts", + "line": 57, + "name": "should allow no timestamp", + "classification": "covered", + "localTests": [ + "TestDocument_EmptyCommitTimeParity", + "TestConformance_NativeParsesJavaScriptEmptyChange" + ], + "rationale": "Zero-timestamp empty changes decode and round-trip with JavaScript and Rust." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should be the same as saveIncremental since heads of the last saveIncremental", + "classification": "covered", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Repeated incremental saves emit only new changes, and a full save advances the incremental cursor." + }, + { + "source": "javascript", + "file": "extra_api_tests.ts", + "name": "should allow you to load incrementally", + "classification": "covered", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Native and Rust incrementally load each other's changes, ignore duplicates, and converge heads." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should incrementally apply changes since the last given version", + "classification": "covered", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Two successive incremental batches apply in both Go-to-Rust and Rust-to-Go directions." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should return true if the document in question has all the heads", + "classification": "covered", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Native and Rust report true for current, historical, and empty head sets." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should return false if the document does not have the heads", + "classification": "covered", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Native and Rust report false for an unknown change hash." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should return the document at its correct heads", + "classification": "covered", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Historical scalar and text values are materialized at the first head after later changes exist." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should report missing dependencies with out-of-order applyChanges", + "classification": "covered", + "localTests": [ + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "A child-first change is queued, reports only its missing parent, then clears the dependency after the parent arrives." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should clear conflicts after assigning a new value", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "A two-actor conflict is resolved by assignment and GetAll returns one value." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should not register any conflicts on repeated assignment", + "classification": "covered", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Repeated writes from one actor retain only the final scalar value." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should not ignore field updates that resolve a conflict", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "The conflict-resolving map update is committed and remains the sole value." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "get change metadata", + "classification": "covered", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "Go validates the actor, sequence, start operation, timestamp, message, dependencies, operations, and hash of an official change." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should return a decoded representation of the change", + "classification": "covered", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "Every operation and metadata field in a JavaScript change is decoded and asserted before byte-compatible re-encoding." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_get_change_meta", + "classification": "covered", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "The native decoder exposes and verifies the official change metadata fields and hash." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_get_last_local_change_generation", + "classification": "covered", + "localTests": [ + "TestConformance_JavaScriptPreservesGoChanges", + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "The most recent generated change is returned with its exact hash and bytes and preserved by JavaScript." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_compressed_changes", + "classification": "covered", + "localTests": [ + "TestDecode_CompressedOfficialChangeFixture" + ], + "rationale": "An official change is compressed, decoded, and verified to preserve its original hash and change type." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "overlong_leb", + "classification": "covered", + "localTests": [ + "TestReaderULEB_RejectsNonCanonicalValue" + ], + "rationale": "The native reader rejects an overlong, non-minimal unsigned LEB128 representation." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "zero_length_data", + "classification": "covered", + "localTests": [ + "TestLoad_InvalidDocument", + "FuzzDecode" + ], + "rationale": "Empty input is a deterministic loader regression seed and part of the native decoder fuzz corpus." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "fuzz_crashers", + "classification": "covered", + "localTests": [ + "TestDecode_OfficialFuzzCrashersDoNotPanic", + "FuzzDecode" + ], + "rationale": "Every pinned upstream fuzz crasher is a deterministic test and fuzz seed for the native decoder." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "load", + "classification": "covered", + "localTests": [ + "TestDecode_OfficialStorageCorpus" + ], + "rationale": "Ordered, compressed, and out-of-order official multi-change fixtures all load successfully." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "obj_id_64bits", + "classification": "covered", + "localTests": [ + "TestDecode_Official64BitObjectIDs" + ], + "rationale": "Official change and document fixtures with a 2^42 object operation ID either reject safely or preserve the full ID." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "ops_on_wrong_objets", + "classification": "covered", + "localTests": [ + "TestDocument_WrongObjectOperationsMatchReference" + ], + "rationale": "Map writes on list/text objects and sequence writes on maps reject consistently with Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "load_doc_with_deleted_objects", + "classification": "covered", + "localTests": [ + "TestDocument_DeletedObjectsSaveLoad" + ], + "rationale": "Deleted list, text, map, and table objects save and load in native and Rust engines with an empty root." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "insert_after_many_deletes", + "classification": "covered", + "localTests": [ + "TestDocument_ManyMapDeletes" + ], + "rationale": "One hundred map insert/delete pairs commit and materialize without index corruption in Go or Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "merge_after_noop_then_real_put", + "classification": "covered", + "localTests": [ + "TestDocument_NoOpMergeAndEqualPutMatchReference" + ], + "rationale": "A fork records a no-op change followed by a real assignment, then merges and reloads in Go and Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "merge_panic_after_putting_value_equal_to_initial_value", + "classification": "covered", + "localTests": [ + "TestDocument_NoOpMergeAndEqualPutMatchReference" + ], + "rationale": "An equal-value assignment creates no operation and does not panic when merged with a real fork update." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "load_incremental_with_corrupted_tail", + "classification": "covered", + "localTests": [ + "TestDocument_IncrementalLoadIgnoresCorruptTail" + ], + "rationale": "Native and Rust apply the complete valid prefix and ignore the corrupt trailing fragment." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "test_load_incremental_partial_load", + "classification": "covered", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "A standalone incremental change batch applies to a peer in both Go-to-Rust and Rust-to-Go directions." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_sync_does_not_apply_incoming_changes", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Native and Rust read-only receivers reject incoming changes in both engine directions." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_empty_peer_syncs_with_data_peer", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "An empty read-only peer exchanges protocol state with a populated peer without applying its document." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "changes_not_sent_to_read_only_peer", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "The publisher discovers read-only mode and quiesces without repeatedly sending document changes." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "peer_discovers_remote_read_only_status", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "PeerReadOnly is asserted after flag exchange for native and Rust publishers." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "switch_read_only_to_read_write_mid_session", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "A read-only peer switches to read-write, requests a reset, receives prior changes, and converges." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "generate_message_after_set_read_only_even_with_in_flight", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyModeOverridesInFlight" + ], + "rationale": "Changing to read-only forces a new message while an earlier message remains in flight." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "generate_message_after_set_read_only_false_even_with_in_flight", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyModeOverridesInFlight" + ], + "rationale": "Changing back to read-write forces a reset message despite an in-flight message." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_peers_read_only", + "classification": "covered", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Two peers with independent changes enter read-only mode and exchange no document changes." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_peers_read_only_converges_to_none", + "classification": "covered", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both read-only peers quiesce while retaining distinct local heads." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_read_only_both_make_local_changes", + "classification": "covered", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both peers begin with independent local changes that remain isolated during read-only synchronization." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_toggle_read_only_to_read_write_simultaneously", + "classification": "covered", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both peers switch to read-write simultaneously and converge their prior local changes." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_toggle_read_only_to_read_write_with_new_changes", + "classification": "covered", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Independent changes created before the simultaneous transition are exchanged after reset." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should not apply incoming changes when read-only", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Read-only behavior is tested in both native-to-Rust and Rust-to-native directions." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should discover peer read-only status", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Both implementations expose the remote read-only state after flags are exchanged." + }, + { + "source": "javascript", + "file": "sync_test.ts", + "name": "should allow switching from read-only to read-write", + "classification": "covered", + "localTests": [ + "TestSyncState_ReadOnlyParity", + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "One-sided and simultaneous read-only transitions reset and converge against Rust." + }, + { + "source": "javascript", + "file": "marks.ts", + "name": "should do unicode sensibly", + "classification": "covered", + "localTests": [ + "TestConformance_NativeUnicodeMarks" + ], + "rationale": "An emoji-range mark followed by a UTF-16 prefix insertion materializes identically in Go and JavaScript." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "can split a block", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_BlockAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently insert and populate block marker maps at matching rich-text positions." + }, + { + "source": "javascript", + "file": "block_test.ts", + "name": "can join a block", + "classification": "covered", + "localTests": [ + "TestPureGoDocument_BlockAuthoringMatchesReference" + ], + "rationale": "Go and Rust delete a block marker, preserve surrounding text, and cross-load identical spans." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "unmark_part_of_range", + "classification": "covered", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "Unmarking the middle of a marked range produces two marked outer spans and an unmarked gap." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "unmark_creates_gaps", + "classification": "covered", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "Null mark operations remove the mark from the requested interior range without affecting adjacent spans." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "mark_splits", + "classification": "covered", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "A single mark split by unmarking materializes as separate marked spans." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "different_adjacent_marks", + "classification": "covered", + "localTests": [ + "TestConformance_NativeBoundaryMarks" + ], + "rationale": "Different marks on adjacent ranges remain separate at head and element anchors." + }, + { + "source": "rust", + "file": "tests/diff_marks.rs", + "name": "marks_on_emoji", + "classification": "covered", + "localTests": [ + "TestConformance_NativeUnicodeMarks" + ], + "rationale": "Marked emoji ranges and subsequent UTF-16 edits materialize with Rust parity." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "merge_concurrent_map_prop_updates", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Twenty deterministic multi-change histories update shared map properties concurrently and compare every conflict with an independently merged Rust history." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_updates_of_same_field", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Both actors repeatedly assign the same bounded key set before merge-order and Rust differential checks." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_updates_of_same_list_element", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Both actors replace shared base-list elements in deterministic randomized histories and compare the merged sequence with Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_insertions_at_different_list_positions", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Independent actors insert throughout the same base list and both merge orders are compared with Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_insertions_at_same_list_position", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "The bounded random index space repeatedly creates same-position concurrent insertions and verifies deterministic Rust ordering." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_assignment_and_deletion_of_a_map_entry", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Each actor independently assigns and deletes the same bounded key set before conflict comparison with Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_assignment_and_deletion_of_list_entry", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Concurrent list replacements and deletions are merged in both orders and compared element-by-element with Rust." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "it should handle conflicts the same in merges as with loads", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Native and Rust independently construct and merge identical histories, then compare all scalar conflicts." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "should obtain the same conflicts, regardless of merge order", + "classification": "covered", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Left-first and right-first merges converge to identical conflicts in native and Rust engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "does_not_interleave_sequence_insertions_at_same_position", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Two multi-value insertions at the same position remain contiguous and match Rust ordering." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "The greater actor's insertion chunk is ordered before the lesser actor's chunk in Go and Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Actor-order-independent construction converges to the same chunk ordering in Go and Rust." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "insertion_after_a_deleted_list_element", + "classification": "covered", + "localTests": [ + "TestDocument_InsertAfterConcurrentDeleteMatchesReference" + ], + "rationale": "An insertion anchored after an element deleted concurrently remains visible in the correct position." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should not interleave sequence insertions at the same position", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Concurrent insertion chunks remain contiguous with native and Rust parity." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should handle insertion by greater actor ID", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Explicit deterministic actors verify greater-ID insertion ordering." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should handle insertion by lesser actor ID", + "classification": "covered", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Explicit deterministic actors verify lesser-ID insertion ordering." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should handle insertion after a deleted list element", + "classification": "covered", + "localTests": [ + "TestDocument_InsertAfterConcurrentDeleteMatchesReference" + ], + "rationale": "Delete/insert concurrency preserves the insertion anchored to a deleted element." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "rollback_with_no_ops", + "classification": "covered", + "localTests": [ + "TestDocument_RollbackMatchesReference" + ], + "rationale": "Rollback with no pending operations returns zero in native and Rust engines." + }, + { + "source": "javascript", + "file": "basic_test.ts", + "name": "it should be able to roll back a transaction", + "classification": "covered", + "localTests": [ + "TestDocument_RollbackMatchesReference" + ], + "rationale": "Pending scalar and object operations are rolled back, committed values and heads remain unchanged, and Rust reports the same cancelled operation count." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "repeated_list_assignment_which_resolves_conflict_not_ignored", + "classification": "covered", + "localTests": [ + "TestRust_RepeatedListAssignmentResolvesConflict" + ], + "rationale": "A list element is assigned across a merge and then reassigned; both engines resolve to the single winning value and agree on heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "add_increments_only_to_preceeded_values", + "classification": "covered", + "localTests": [ + "TestRust_AddIncrementsOnlyToPreceededValues" + ], + "rationale": "Two actors create and increment separate counters at the same key; the increments stay attached to their own counters, yielding conflicting values 1 and 3 on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "assignment_conflicts_of_different_types", + "classification": "covered", + "localTests": [ + "TestRust_AssignmentConflictsOfDifferentTypes" + ], + "rationale": "Three actors assign a string, a list, and a map to the same key; both engines pick the same winner and produce identical heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "changes_within_conflicting_map_field", + "classification": "covered", + "localTests": [ + "TestRust_ChangesWithinConflictingMapField" + ], + "rationale": "A string and a populated map conflict at one key; both engines expose the winning map with its inner value and agree on heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "changes_within_conflicting_list_element", + "classification": "covered", + "localTests": [ + "TestRust_ChangesWithinConflictingListElement" + ], + "rationale": "Two actors replace the same list element with maps and mutate them across merges; both engines expose the same winning map contents and heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrently_assigned_nested_maps_should_not_merge", + "classification": "covered", + "localTests": [ + "TestRust_ConcurrentlyAssignedNestedMapsShouldNotMerge" + ], + "rationale": "Two actors assign different maps to the same key; the maps do not merge and the winning map keeps exactly one key on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_deletion_of_same_list_element", + "classification": "covered", + "localTests": [ + "TestRust_ConcurrentDeletionOfSameListElement" + ], + "rationale": "Both actors delete the same list element concurrently; the element is removed once and the surviving order matches on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_updates_at_different_levels", + "classification": "covered", + "localTests": [ + "TestRust_ConcurrentUpdatesAtDifferentLevels" + ], + "rationale": "One actor edits a nested map while another deletes it; the deletion wins and only the sibling list remains on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "concurrent_updates_of_concurrently_deleted_objects", + "classification": "covered", + "localTests": [ + "TestRust_ConcurrentUpdatesOfConcurrentlyDeletedObjects" + ], + "rationale": "One actor updates a nested object another actor deleted concurrently; the deletion wins and the parent becomes empty on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "insertion_consistent_with_causality", + "classification": "covered", + "localTests": [ + "TestRust_InsertionConsistentWithCausality" + ], + "rationale": "Interleaved head insertions across repeated merges produce the causally ordered list one,two,three,four on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_restore_complex1", + "classification": "covered", + "localTests": [ + "TestRust_SaveRestoreComplex1" + ], + "rationale": "A todo list with a conflicting title survives save and reload; both engines expose both conflicting titles and the retained boolean." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_restore_complex_transactional", + "classification": "covered", + "localTests": [ + "TestRust_SaveRestoreComplexTransactional" + ], + "rationale": "The transactional variant groups writes into single commits; the reloaded document exposes the same conflicting titles and boolean on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "big_list", + "classification": "covered", + "localTests": [ + "TestRust_BigList" + ], + "rationale": "A list of many null elements is replaced with map objects; both engines materialize N+1 maps that survive a cross-engine save/load with identical heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "invalid_index", + "classification": "covered", + "localTests": [ + "TestRust_InvalidIndex" + ], + "rationale": "Inserting or putting beyond the end of a list is rejected by both engines while an in-bounds put succeeds." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "has_our_changes", + "classification": "covered", + "localTests": [ + "TestRust_HasOurChanges" + ], + "rationale": "Two peers with concurrent changes synchronize until each contains the other's change and both converge to identical heads on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head", + "classification": "covered", + "localTests": [ + "TestRust_LoadIncrementalWithCommonHead" + ], + "rationale": "Incremental loads that share a common head are not skipped; both engines end with the expected merged two-head frontier." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "regression_nth_miscount", + "classification": "covered", + "localTests": [ + "TestRust_RegressionNthMiscount" + ], + "rationale": "A 30-element list of nested maps is indexed after insert-then-replace at every position; both engines resolve each element to the expected map and value with identical heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "regression_nth_miscount_smaller", + "classification": "covered", + "localTests": [ + "TestRust_RegressionNthMiscountSmaller" + ], + "rationale": "A list spanning several op-tree nodes (B*4 elements) is inserted then overwritten at each index; both engines read back every scalar with identical heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "regression_insert_opid", + "classification": "covered", + "localTests": [ + "TestRust_RegressionInsertOpid" + ], + "rationale": "Interleaved insert-then-overwrite operations round-trip through a cross-engine save/load with every list value preserved and identical heads." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "rollback_with_several_actors", + "classification": "covered", + "localTests": [ + "TestRust_RollbackWithSeveralActors" + ], + "rationale": "Uncommitted edits by a third forked actor are rolled back, leaving the document byte-identical to the forked-from state on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "save_with_ops_which_reference_actors_only_via_delete", + "classification": "covered", + "localTests": [ + "TestRust_SaveWithOpsReferencingActorsOnlyViaDelete" + ], + "rationale": "A merged delete op references a fork's actor only through successors; the document still saves and reloads cleanly across both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "simple_bad_saveload", + "classification": "covered", + "localTests": [ + "TestRust_SimpleBadSaveload" + ], + "rationale": "An empty commit interleaved between real changes does not corrupt the save/load round trip on either engine." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "bad_change_on_optree_node_boundary", + "classification": "covered", + "localTests": [ + "TestRust_BadChangeOnOptreeNodeBoundary" + ], + "rationale": "A document grown across an op-tree node boundary is saved, reloaded elsewhere, and a further change is transferred and reloaded with matching state and heads on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "list_counter_del", + "classification": "covered", + "localTests": [ + "TestRust_ListCounterDel" + ], + "rationale": "Three actors write conflicting counters and an integer to the same list elements; increments apply to every conflicting counter and delete the non-counter, and the conflict sets, lengths, and reloads match the reference engine." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "first_response_is_some_even_if_no_changes", + "classification": "covered", + "localTests": [ + "TestRustSync_FirstResponseIsSomeEvenIfNoChanges" + ], + "rationale": "Two peers at identical heads still exchange a first sync message so each learns the other's heads." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "should_allow_simultaneous_messages_during_synchronisation", + "classification": "covered", + "localTests": [ + "TestRustSync_AllowSimultaneousMessages" + ], + "rationale": "Two peers each committing five concurrent changes exchange messages in both directions and converge to identical heads with each other's keys." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_read_only_one_makes_local_changes", + "classification": "covered", + "localTests": [ + "TestRustSync_BothReadOnlyOneMakesLocalChanges" + ], + "rationale": "Both peers are read-only; local changes on one are not transferred to the other and the session still quiesces." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_read_only_simultaneous_changes_during_sync", + "classification": "covered", + "localTests": [ + "TestRustSync_BothReadOnlySimultaneousChanges" + ], + "rationale": "Both read-only peers make simultaneous changes across two rounds; neither receives the other's data and both quiesce." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_peer_new_changes_between_sync_rounds", + "classification": "covered", + "localTests": [ + "TestRustSync_ReadOnlyPeerNewChangesBetweenRounds" + ], + "rationale": "A read-only publisher's new changes flow to the read-write peer as a conflict while the publisher never receives the consumer's data." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_publisher_to_multiple_consumers", + "classification": "covered", + "localTests": [ + "TestRustSync_ReadOnlyPublisherToMultipleConsumers" + ], + "rationale": "A read-only publisher's changes reach two independent consumers, and one consumer's changes never reach the other through the publisher." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_fully_connected_triangle", + "classification": "covered", + "localTests": [ + "TestRustSync_ReadOnlyFullyConnectedTriangle" + ], + "rationale": "A read-only peer publishes to two read-write peers that then merge; both gain all three change sets while the publisher keeps only its own." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "stale_shared_heads_after_read_only_sync", + "classification": "covered", + "localTests": [ + "TestRustSync_StaleSharedHeadsAfterReadOnlySync" + ], + "rationale": "A consumer that already has the read-only publisher's changes via a third peer re-syncs directly and still quiesces without the publisher accepting data." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_peer_receives_same_changes_from_two_peers", + "classification": "covered", + "localTests": [ + "TestRustSync_ReadOnlyPeerReceivesSameChangesFromTwoPeers" + ], + "rationale": "A read-only publisher announced the same changes by two peers keeps only its own history and later distributes a new change to both." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "switch_read_write_to_read_only_mid_session", + "classification": "covered", + "localTests": [ + "TestRustSync_SwitchReadWriteToReadOnlyMidSession" + ], + "rationale": "A peer switched to read-only mid-session still publishes its own new change but no longer accepts the peer's new change." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "switch_read_only_to_read_write_with_multiple_rounds", + "classification": "covered", + "localTests": [ + "TestRustSync_SwitchReadOnlyToReadWriteMultipleRounds" + ], + "rationale": "A read-only peer that ignored several rounds of changes receives all of them after switching to read-write and converges." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "toggle_read_only_multiple_times", + "classification": "covered", + "localTests": [ + "TestRustSync_ToggleReadOnlyMultipleTimes" + ], + "rationale": "Toggling read-only on and off across rounds gates change acceptance correctly and converges once read-write." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "both_toggle_after_multiple_read_only_rounds", + "classification": "covered", + "localTests": [ + "TestRustSync_BothToggleAfterMultipleReadOnlyRounds" + ], + "rationale": "Both peers accumulate changes across read-only rounds and exchange all of them after both switch to read-write." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_insert_merges_correctly", + "classification": "covered", + "localTests": [ + "TestRustBatch_MergesCorrectly" + ], + "rationale": "Two forks each batch-create a distinct nested map; after merge both objects and their fields are present, verified against the reference." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "multiple_batch_inserts", + "classification": "covered", + "localTests": [ + "TestRustBatch_MultipleInserts" + ], + "rationale": "Three sequential batch-created maps each retain their scalar fields on both engines." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_insert_into_existing_map", + "classification": "covered", + "localTests": [ + "TestRustBatch_InsertIntoExistingMap" + ], + "rationale": "Batch-creating a nested map inside a populated map preserves the existing key and materializes the new nested values." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "batch_put_overwrite_with_nested_structure", + "classification": "covered", + "localTests": [ + "TestRustBatch_PutOverwriteWithNestedStructure" + ], + "rationale": "Overwriting a list element with a deeply nested map and child list keeps the sibling element and materializes the nested structure." + }, + { + "source": "rust", + "file": "tests/batch_insert.rs", + "name": "splice_merges_correctly", + "classification": "covered", + "localTests": [ + "TestRustBatch_SpliceMergesCorrectly" + ], + "rationale": "Concurrent hydrated splices into a shared list merge to length three with the shared element retained." + }, + { + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "name": "load_incremental_change_without_deps_throws", + "classification": "covered", + "localTests": [ + "TestRustOrphans_LoadIncrementalChangeWithoutDepsThrows" + ], + "rationale": "Loading a bare change chunk whose dependencies are absent is rejected by both engines." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "should_not_reply_if_we_have_no_data_after_first_round", + "classification": "covered", + "localTests": [ + "TestRustSync_ShouldNotReplyIfNoDataAfterFirstRound" + ], + "rationale": "Two empty peers exchange a mandatory first message each, then fall silent once neither has anything to send." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "read_only_peer_concurrent_changes_during_sync", + "classification": "covered", + "localTests": [ + "TestRustSync_ReadOnlyPeerConcurrentChanges" + ], + "rationale": "A read-only peer that commits a change mid-flight still publishes it to the read-write peer, which the read-only peer never consumes in return." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "marks_in_spans_cross_block_markers", + "classification": "covered", + "localTests": [ + "TestRustRichText_MarksInSpansCrossBlockMarkers" + ], + "rationale": "A mark spanning text split by a block marker is reported as two marked text spans around the block, identically on both engines." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "test_mark_behavior_on_delete_insert", + "classification": "covered", + "localTests": [ + "TestRustRichText_MarkBehaviorOnDeleteInsert" + ], + "rationale": "Deleting all marked text and inserting new text leaves the new text unmarked on both engines." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "spans_consolidates_marks_which_are_empty_due_to_deleted_marks", + "classification": "covered", + "localTests": [ + "TestRustRichText_SpansConsolidateEmptyDueToDeletedMarks" + ], + "rationale": "Overlapping bold/italic marks partially removed consolidate into the expected three spans on both engines." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks", + "classification": "covered", + "localTests": [ + "TestRustRichText_SpansConsolidateDeletedThenEmptyMarks" + ], + "rationale": "Marking then unmarking a leading range consolidates back to a single unmarked span on both engines." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks", + "classification": "covered", + "localTests": [ + "TestRustRichText_SpansConsolidateEmptyThenDeletedMarks" + ], + "rationale": "Marking then unmarking a trailing range consolidates back to a single span on both engines." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "text_complex_block_properties", + "classification": "covered", + "localTests": [ + "TestRustRichText_ComplexBlockProperties" + ], + "rationale": "A block populated with nested text and list properties materializes identical span block values on both engines." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "mark_created_after_insertion", + "classification": "covered", + "localTests": [ + "TestRustRichText_MarkCreatedAfterInsertion" + ], + "rationale": "Two disjoint strong marks created after insertion produce identical spans on both engines." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "spans_are_consolidated_in_the_presence_of_zero_length_spans", + "classification": "covered", + "localTests": [ + "TestRustRichText_SpansConsolidatedWithZeroLengthSpans" + ], + "rationale": "Zero-length marks do not fragment the span stream; both engines report a single consolidated text span." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "empty_marks_before_block_marker_dont_repeat_text", + "classification": "covered", + "localTests": [ + "TestRustRichText_EmptyMarksBeforeBlockMarker" + ], + "rationale": "Empty marks and text inserted around block markers do not duplicate text; both engines report two block spans followed by a single text span." + }, + { + "source": "rust", + "file": "tests/block_tests.rs", + "name": "test_splice_with_mark", + "classification": "covered", + "localTests": [ + "TestRustRichText_SpliceWithMark" + ], + "rationale": "Replacing text exactly at two mark boundaries preserves the expanding mark while dropping the non-expanding mark, matching upstream issue #935." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "get_marks_at_heads", + "classification": "covered", + "localTests": [ + "TestRustRichText_GetMarksAtHeads" + ], + "rationale": "Marks active at a text index resolved at a historical frontier match the reference after the mark is later removed." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "inserting_text_near_deleted_marks", + "classification": "covered", + "localTests": [ + "TestRustRichText_InsertingTextNearDeletedMarks" + ], + "rationale": "Inserting text around ranges whose marked elements were deleted yields the same active marks as the reference." + }, + { + "source": "rust", + "file": "tests/text.rs", + "name": "removed_marks_should_not_appear_in_get_marks", + "classification": "covered", + "localTests": [ + "TestRustRichText_RemovedMarksNotInGetMarks" + ], + "rationale": "A mark removed by a null value does not appear in the active mark set on either engine." + }, + { + "source": "rust", + "file": "src/sync.rs", + "name": "should_handle_lots_of_branching_and_merging", + "classification": "covered", + "localTests": [ + "TestRustSync_BranchingAndMerging" + ], + "rationale": "Two peers exchange many concurrent changes, a third peer's concurrent change is merged into one, and a final synchronization converges both peers to identical heads on both engines." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should support Date objects in maps", + "classification": "covered", + "localTests": [ + "TestConformance_NativePreservesJavaScriptDataModel" + ], + "rationale": "A JavaScript Date stored in a map round-trips through the native Go engine and is re-read identically by JavaScript." + }, + { + "source": "javascript", + "file": "legacy_tests.ts", + "name": "should support Date objects in lists", + "classification": "covered", + "localTests": [ + "TestConformance_NativePreservesJavaScriptDataModel" + ], + "rationale": "A JavaScript Date stored in a list round-trips through the native Go engine and is re-read identically by JavaScript." + }, + { + "source": "javascript", + "file": "cursors.ts", + "name": "should allow dates from an existing document to be used in another document", + "classification": "covered", + "localTests": [ + "TestConformance_DatesFlowBetweenDocuments" + ], + "rationale": "A JavaScript Date read from one document as a timestamp scalar is written into another document through the native engine and re-read as a Date by JavaScript, in both a map and a list." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "stats_smoke_test", + "classification": "covered", + "localTests": [ + "TestDocument_StatsMatchReference" + ], + "rationale": "Two committed puts report two changes, two ops, and one actor identically on the native and reference engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "basic_test", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_Basic" + ], + "rationale": "Materializing a document with a scalar, nested map, list, and text produces the same ordered patch stream on the native and reference engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_deleted_ops_omitted", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_DeletedOpsOmitted" + ], + "rationale": "Deleted scalars, map keys, and list elements are omitted from the materialization patches identically on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_text_spliced", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_TextSpliced" + ], + "rationale": "Sequential text splices materialize as a single consolidated splice_text patch identically on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_counters", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_Counters" + ], + "rationale": "A counter with merged increments conflicting with a concurrent value materializes as a conflicted put of the summed counter on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_multiple_list_insertions", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_MultipleListInsertions" + ], + "rationale": "Multiple list insertions materialize as the same grouped insert patch on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_concurrent_insertions_at_same_index", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_ConcurrentInsertions" + ], + "rationale": "Concurrent insertions at the same index materialize in the same converged order on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_insert_objects", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_InsertObjects" + ], + "rationale": "Inserting an object into a list materializes the insert and the nested object's properties identically on both engines." + }, + { + "source": "rust", + "file": "src/automerge/current_state.rs", + "name": "test_insert_and_update", + "classification": "covered", + "localTests": [ + "TestRustCurrentState_InsertAndUpdate" + ], + "rationale": "Inserting then overwriting list elements materializes the updated values identically on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "diff_should_reverse_deletion_of_object_in_list_correctly", + "classification": "covered", + "localTests": [ + "TestRustDiff_ReverseDeletionOfObjectInList" + ], + "rationale": "Diffing after a list-object deletion back to before re-inserts the object and materializes its text identically on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "diff_should_reverse_deletion_of_object_in_map_correctly", + "classification": "covered", + "localTests": [ + "TestRustDiff_ReverseDeletionOfObjectInMap" + ], + "rationale": "Diffing after a map-object deletion back to before re-puts the object and materializes its text identically on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "diff_should_reverse_deletion_of_block_in_text_correctly", + "classification": "covered", + "localTests": [ + "TestRustDiff_ReverseDeletionOfBlockInText" + ], + "rationale": "Diffing after a text-block deletion back to before re-inserts the block and materializes its properties identically on both engines." + }, + { + "source": "rust", + "file": "tests/test.rs", + "name": "large_patches_in_lists_are_correct", + "classification": "covered", + "localTests": [ + "TestRustDiff_LargePatchesInLists" + ], + "rationale": "A string list element counts as one index, so a run of 500 following objects is indexed identically in the native and reference diff patch streams." + } +] diff --git a/pkg/automerge/PARITY_PLAN.md b/pkg/automerge/PARITY_PLAN.md new file mode 100644 index 0000000000..1faebf8df1 --- /dev/null +++ b/pkg/automerge/PARITY_PLAN.md @@ -0,0 +1,375 @@ +# Automerge parity plan + +## Goal + +Make the native Go engine a complete behavioral replacement for the public +Rust `automerge` 0.10.0 engine while retaining a small JavaScript 3.4.0 boundary +suite for behavior introduced by the browser binding. + +Completion means: + +- every required entry in `testdata/upstream-parity.json` is mapped to an + executable Go test; +- `make audit-automerge-interop` passes with zero pending required entries; +- documents, changes, heads, cursors, marks, blocks, and sync messages can move + through Go, Rust, and JavaScript in any order without changing transferred + identities or materialized state; +- race, malformed-input, fuzz, and conformance suites pass; and +- no production fallback silently bypasses the native engine. + +This plan excludes Rust-private storage structures and JavaScript +proxy/packaging mechanics. They remain recorded in the manifest but do not +define Go engine behavior. + +## Pinned sources + +| Surface | Version | Commit | +|---|---|---| +| Rust `automerge` | 0.10.0 with UTF-16 indexing | `a4f584c86358dd07f83f36708573e1c8d1bd8161` | +| JavaScript `@automerge/automerge` | 3.4.0 | `f8b0911dc9d86265dd62934b7dc782571e3a7fcb` | + +The generator verifies both Git revisions before updating the ledger. + +## Current baseline + +| Classification | Covered | Pending | +|---|---:|---:| +| Required Rust + JavaScript boundary behavior | 206 | 156 | +| Non-blocking JavaScript convenience behavior | 44 | 196 | +| Private or language-specific behavior | 110 | β€” | + +The generated manifest is the authoritative leaf-level list. Counts in this +document are informational and must be updated whenever the manifest changes. + +## Required source backlog + +| Source | Pending | Missing behavior | +|---|---:|---| +Total required pending entries: **0**. + +Every interop-required upstream test is now covered. The isolate/integrate +incremental patch ordering +(`incorrect_patches_produced_when_isolating_and_integrating`) is reproduced by +having the native incremental diff chain through the isolation frontiers recorded +in the window: it emits `diff(cursor -> isolation frontier)` followed by +`diff(isolation frontier -> current heads)`, which yields the reference's +"reset then rebuild" patch stream (deletes for the prior keys, conflicting puts, +and a splice only for each winning object). This special path activates only when +an isolate occurred since the diff cursor was last set, so ordinary incremental +diffs are unchanged. Materialization now also skips losing conflict alternatives +at a map key so only the winning object's content is spliced. + +`observe_counter_change_application` is covered as a native-matches-reference +differential: the pinned reference (`automerge` 0.10.0 embedded as WASM) collapses +an applied create-and-increment counter change into a single `put_map` of the +materialized value through `diff_incremental` rather than emitting per-operation +patches, and native reproduces that reference behavior exactly. + +DEFLATE compression on save is now covered: the native save compresses change +chunks whose body reaches the reference DEFLATE_MIN_SIZE threshold (small changes +stay byte-identical), and SaveNoCompress mirrors AutoCommit::save_nocompress. + +Transaction isolation is now covered: `isolate` pins reads and writes to a +historical frontier using derived concurrency actors (matching Rust's +`with_concurrency` scheme), keeps merges hidden until `integrate`, and supports +repeated isolate/integrate cycles. The value-level isolation tests (`can_isolate`, +`can_transaction_at`, `update_text_change_at`) pass on both engines; only the +patch-ordering test (`incorrect_patches_produced_when_isolating_and_integrating`) +remains, because it asserts the exact incremental patch stream produced by Rust's +patch log across isolate/integrate, which native's state-comparison diff does not +reproduce. + +Orphan retention across save/load is now covered without a full document-chunk +encoder: the native save appends retained orphan changes and the native load +falls back to a dependency-tolerant path that applies every change whose +dependencies are satisfiable and queues the rest (still failing a load that can +apply nothing, so a bare orphan without a base is rejected as before). + +The V2 sync internals are now covered: the empty-message codec round-trips to +the reference wire bytes, and Bloom false-positive recovery is verified on both +engines using the reference engine's real Bloom filter (exposed through the +`am_bloom_contains` FFI) to locate genuine false positives. The native engine's +V2 sync uses exact head comparison instead of Bloom filters, so it is immune to +false positives by construction while still converging in these scenarios. + +Legacy V1 sync protocol interoperability (V1↔V2 sessions, compressed changes in +V1 sessions, and old-peer capability fallback) is intentionally out of scope: +this project uses only the V2 sync protocol. Those upstream cases are recorded +as api-convenience rather than interop-required. Rust-internal library rustdoc +examples, the Rust owned/manual transaction object API, `Send` trait checks, and +JavaScript binding-type helpers (ImmutableString/RawString, legacy Text-as-array, +proxy/change-callback) are likewise recorded as api-convenience or +language-specific rather than interop-required. + +## Known native defects (found by parity reproduction, fix pending) + +**Mark boundary after emptying an anchored range (insert-time anchoring).** +Reproduced by the randomized `TestRustText_MarksAreOkay` generator while +cross-checking mark *values* against the reference (an assertion stronger than +upstream `marks_are_okay`, which only checks span consolidation and text). Two +minimal cases diverge because native chooses list-insertion keys without +consulting mark begin/end ops and their `expand` flag, whereas Rust's insert +query (`op_set2/op_set/insert.rs`) positions a new element relative to +surrounding `MarkBegin(expand.before())`/`MarkEnd(expand.after())` ops: + +- Text inserted at a mark's head boundary *while marked content is still + visible* must inherit the expanding mark even after that content is later + deleted (native currently drops the mark). +- Text inserted at the head *after the entire marked range has been deleted* + must not inherit the mark (native currently applies it). + +Both cases still satisfy the upstream `marks_are_okay` invariants (spans stay +consolidated and reproduce the text), so `rust/tests/text.rs::marks_are_okay` +is covered; the value-level divergence is tracked here. A full fix requires +expand- and visibility-aware insertion positioning in the native sequence path +(porting the Rust `InsertQuery` boundary logic) plus computing spans by running +a mark state machine over mark ops embedded in sequence order. + +## Optional JavaScript convenience backlog + +These do not block Go/Rust engine parity but remain tracked: + +| Source | Pending | +|---|---:| +| Legacy JavaScript proxy and mutation API | 105 | +| JavaScript patch application/callback helpers | 31 | +| JavaScript basic convenience APIs | 25 | +| JavaScript sync wrapper duplicates | 22 | +| Fragments and `changeAt` wrappers | 8 | +| Unstable change API | 3 | +| JavaScript conflicted-proxy mutation | 3 | +| Anonymization helper | 1 | + +## Workstream 1: storage and change identity + +Implement and verify: + +- complete document, change, compressed-change, and bundle parsing; +- canonical encoding for every operation and scalar column; +- expanded/compressed change byte and hash stability; +- 64-bit object IDs and actor tables referenced only by deletes; +- partial and incremental loading with corrupted tails; +- orphan preservation/discard rules; +- missing dependency behavior; +- unknown-column and unknown-scalar preservation; +- no-op and empty changes across save/load; +- official malformed fixtures and fuzz crashers; and +- V1 storage compatibility. + +Acceptance: + +- every storage fixture loads or rejects identically to Rust; +- Go-authored changes retain their hash after Rust and JavaScript relay; +- Rust-authored snapshots can be extended and forwarded by Go; and +- all storage and parser manifest entries are covered. + +## Workstream 2: maps, lists, scalars, counters, and conflicts + +Complete: + +- conflicts involving different scalar/object types; +- nested map/list conflicts; +- updates inside conflicted objects; +- concurrent assignment/deletion; +- updates to concurrently deleted objects; +- counter increments attached only to the values they precede; +- list-counter deletion semantics; +- actor/counter sequence ordering; +- insertions around large deleted runs; +- causality-preserving insertion chunks; +- large-list indexing and regression fixtures; +- wrong-object and invalid-index errors; and +- merge after no-op/equal-value updates. + +Acceptance: + +- deterministic and randomized Go/Rust histories match values, conflicts, + heads, and transferred hashes; +- all `rust/tests/test.rs` core-model entries are covered; and +- three-peer forwarding preserves every conflict. + +## Workstream 3: text encodings and cursors + +Complete: + +- UTF-16 length/get/put/insert/delete behavior; +- update-text minimal diff behavior; +- grapheme and combining-character cases; +- cursors at historical heads; +- start/end and movement bias through nested deletions; +- cursor reuse across compatible documents; +- text inside lists/maps; +- string-to-text migration; and +- cursor patch source metadata at the JavaScript boundary. + +Acceptance: + +- every valid JavaScript UTF-16 position resolves identically in Go and Rust; +- every official cursor byte sequence round-trips; +- cursor-addressed edits converge after arbitrary concurrent changes; and +- all text-encoding and curated JavaScript cursor entries are covered. + +## Workstream 4: marks and blocks + +Complete: + +- all mark expansion modes at both boundaries; +- adjacent, nested, overlapping, empty, and zero-length marks; +- marks with changing names, values, and scalar types; +- marks on emoji, combining characters, whitespace, and deleted text; +- marks crossing block markers; +- block split/join/replace and block attribute updates; +- simultaneous text, mark, and block updates; +- historical marks and blocks; +- `updateSpans` semantic equivalent; +- list/table/divider/code block marker values; and +- mark/block patches and merge diffs. + +Acceptance: + +- `spans` output matches Rust and curated JavaScript for every fixture; +- Go-authored marks/blocks load and remain editable everywhere; +- Rust/JS-authored rich text can be edited by Go without normalization loss; and +- all mark, block, and rich-text manifest entries are covered. + +## Workstream 5: synchronization + +Complete (V2 protocol only; legacy V1 interoperability is out of scope): + +- Bloom filter false positives and chains; +- explicit requested changes and nonexistent requests; +- branching/merging histories; +- simultaneous messages and edits while in flight; +- persisted sessions and process/data-loss recovery; +- all read-only/reset transitions; +- publishers with multiple consumers; +- fully connected and relay topologies; and +- stale shared heads and duplicate paths. + +Acceptance: + +- every official sync test quiesces within a fixed bound; +- all peer combinations (Go/Go, Go/Rust, Rust/Go) converge; +- duplicate, delayed, reordered, and replayed messages are safe; and +- all V2 sync entries are covered (legacy V1 interoperability is out of scope). + +## Workstream 6: transactions, history, patches, and current state + +Complete: + +- owned/manual transactions; +- pending reads and writes; +- commit options and empty transactions; +- rollback with multiple actors; +- isolation and transactions at heads; +- historical map/list/text/mark reads; +- reverse diffs after object/block deletion; +- map/list/text/increment/mark/block patches; +- large list patches; +- patch-log ownership errors without panics; +- current-state materialization with conflicts; and +- incremental diff and patch callback semantics. + +Go APIs can be idiomatic; they must expose the same observable capability. + +Acceptance: + +- transaction and historical outcomes match Rust; +- patch sequences are semantically equivalent and use UTF-16 indexes; +- applying patches reconstructs the expected hydrated state; and +- all transaction/current-state/patch entries are covered. + +## Workstream 7: batch and hydration completion + +Complete the remaining batch behaviors: + +- patch generation; +- merge after batch insertion; +- scalar-target rejection; +- transaction integration; +- repeated batches; +- replacement of existing nested maps; and +- parity between batch and individual operation output. + +Acceptance: + +- batch output loads and merges identically in Go and Rust; +- rollback leaves no batch-created objects; +- nested values and text preserve object identity; and +- all active batch entries are covered. + +## Workstream 8: curated JavaScript boundary + +Retain only: + +- UTF-16 and cursor semantics; +- immutable strings, `Date`, bytes, `BigInt`, integer, unsigned, and float + conversion; +- marks, blocks, tables, and `updateSpans`; +- default/no/provided timestamps; +- JavaScript-generated documents/changes loading in Go; +- Go changes relayed through JavaScript without hash changes; and +- browser sync message compatibility. + +Do not recreate JavaScript proxy syntax, packaging, export aliases, or callback +ergonomics in Go. + +## Workstream 9: state-machine differential and fuzzing + +Expand the neutral scenario runner to support: + +- fork, merge, apply changes, and sync schedules; +- every scalar/object operation; +- marks, blocks, cursors, and historical reads; +- transactions and rollback; +- malformed and partial bytes; and +- deterministic network faults. + +Run every scenario independently through native Go and native Rust, then route +each serialized result through JavaScript boundary checks where relevant. + +Fuzz: + +- documents, changes, compressed chunks, sync messages, cursors, and patches; +- stateful map/list/text/mark/block histories; +- cross-engine generated bytes; +- dependency reordering and duplicate delivery; and +- load/save/merge cycles under memory and size limits. + +Every discovered failure becomes a deterministic regression before its fix. + +## Workstream 10: performance and production readiness + +Benchmark identical native Go/Rust workloads for: + +- long-lived map/list histories; +- text typing and large pastes; +- marks, blocks, and tables; +- save/load with compacted and uncompacted histories; +- two- and three-peer sync; +- merge-heavy branching histories; and +- snapshot compaction. + +Performance does not waive correctness. Optimization changes must pass the +entire parity and fuzz suite. + +Before enabling the native engine without fallback: + +1. `make audit-automerge-interop` passes. +2. Go race tests pass. +3. Rust/JavaScript conformance passes. +4. Fuzz smoke and retained corpus pass. +5. Benchmarks show no unbounded regression. +6. The exported Go surface contains only supported, tested capabilities. + +## Implementation rules + +- Do not mark an upstream test covered because a nearby test looks similar. +- Every mapping names the exact local test and explains the equivalent + assertion. +- Reproduce a failing upstream behavior before modifying the engine. +- Keep Rust/WASM and JavaScript oracle code independent from native Go logic. +- Preserve original bytes for transferred immutable changes. +- Never silently normalize unknown protocol data. +- Keep all bounds explicit for untrusted documents and sync messages. +- Do not claim completion while any required manifest entry remains pending. diff --git a/pkg/automerge/README.md b/pkg/automerge/README.md index 03f61dbfcd..5cc1f027e2 100644 --- a/pkg/automerge/README.md +++ b/pkg/automerge/README.md @@ -35,15 +35,18 @@ make generate-automerge-reference ## Compatibility checks Ordinary Go tests cover binary round trips, UTF-16 text offsets, concurrent -changes, randomized text histories, merge convergence, native/reference sync, -rich-text spans, cursor movement, and lifecycle behavior: +changes, randomized concurrent text histories, dependency reordering, merge +forwarding, duplicate messages, persisted sync sessions, three-peer relays, +native/reference sync, rich-text spans, cursor movement, and lifecycle +behavior: ```sh go test -race ./pkg/automerge/... ``` The cross-language suite additionally loads Go documents in the official -JavaScript implementation and JavaScript documents in Go: +JavaScript implementation, JavaScript documents in Go, and verifies that +JavaScript preserves the exact hashes and bytes of Go-generated changes: ```sh npm install @@ -52,3 +55,82 @@ make test-automerge-conformance The conformance oracle is deliberately separate from the Go implementation so the two paths do not share adapter code. + +Neutral interoperability scenarios live in `testdata/scenarios`. The same JSON +operations are executed independently by native Go, native Rust/WASM, and +JavaScript. Independently authored changes may have different hashes when an API +chooses a different valid operation order; the gate instead requires every +engine to load, preserve, extend, and semantically materialize every other +engine's output while preserving each transferred change's original hash. + +Fuzz targets exercise document and change decoding, sync message parsing and +round trips, and rich-text projection. Every production failure should be +reduced to a deterministic regression seed before its fix is merged. + +```sh +make fuzz-automerge AUTOMERGE_FUZZ_TIME=30s +``` + +Native and Rust/WASM benchmarks cover warm document creation, map mutation, +character-by-character text editing, 10,000-character save/load, and initial +native/reference synchronization: + +```sh +make benchmark-automerge +``` + +For a direct optimized native-Go versus native-Rust comparison, use the shared +worker harness. It executes identical actors, operations, commit metadata, +warmups, and sample counts, and rejects results unless both engines produce the +same document checksum: + +```sh +make benchmark-automerge-native +``` + +## Upstream parity ledger + +The complete implementation backlog, execution order, and acceptance criteria +are maintained in [`PARITY_PLAN.md`](PARITY_PLAN.md). + +`testdata/upstream-parity.json` inventories every active Rust test reported by +the pinned test harness, every JavaScript leaf test, and every JavaScript +packaging scenario at the pinned upstream revisions: + +- Rust `automerge` 0.10.0, tag `rust/automerge-0.10.0`, commit + `a4f584c86358dd07f83f36708573e1c8d1bd8161`; and +- JavaScript `@automerge/automerge` 3.4.0, tag `js/automerge-3.4.0`, commit + `f8b0911dc9d86265dd62934b7dc782571e3a7fcb`. + +The ledger also records all 16 upstream JavaScript packaging scenarios as +language-specific coverage so they are never confused with Go CRDT semantics. +The Rust denominator is the 361 active harness tests plus 16 doctests reported +by `cargo test -p automerge --features utf16-indexing -- --list`; dormant and +feature-disabled source code is not counted as parity debt. + +Each entry must be mapped to one or more executable local tests or classified +as language-specific with a concrete rationale. Pending entries are visible +debt, not implicit coverage. Wire/state interoperability is gated separately +from language-level convenience APIs: + +```sh +make audit-automerge-interop +``` + +The broader API-parity ledger remains available when working on historical +views, patch callbacks, transaction wrappers, and similar conveniences: + +```sh +make audit-automerge-parity +``` + +Regenerate the inventory from clean checkouts of those exact commits: + +```sh +node packages/automerge-conformance/generate-parity-inventory.mjs \ + --rust-root /path/to/rust/automerge \ + --rust-test-list /path/to/cargo-test-list.txt \ + --javascript-root /path/to/javascript/test \ + --mappings packages/automerge-conformance/parity-mappings.json \ + > pkg/automerge/testdata/upstream-parity.json +``` diff --git a/pkg/automerge/automerge.go b/pkg/automerge/automerge.go index ad414c5b53..448d6f487e 100644 --- a/pkg/automerge/automerge.go +++ b/pkg/automerge/automerge.go @@ -29,6 +29,7 @@ import ( "context" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "sync" @@ -77,22 +78,71 @@ type ( backend interface { Close(context.Context) error Save(context.Context) ([]byte, error) + SaveWithOptions(context.Context, bool) ([]byte, error) + SaveNoCompress(context.Context) ([]byte, error) + Isolate(context.Context, [][32]byte) error + Integrate(context.Context) error + Stats(context.Context) ([]byte, error) + CurrentState(context.Context) ([]byte, error) + Diff(context.Context, [][32]byte, [][32]byte) ([]byte, error) + UpdateDiffCursor(context.Context) error + DiffIncremental(context.Context) ([]byte, error) + SaveIncremental(context.Context) ([]byte, error) + LoadIncremental(context.Context, []byte) (uint64, error) SetActor(context.Context, []byte) error PutString(context.Context, uint32, string, string) error + GetString(context.Context, uint32, string) (string, error) + PutScalar(context.Context, uint32, string, []byte) error + GetScalar(context.Context, uint32, string) ([]byte, error) + GetScalarAtHeads(context.Context, uint32, string, [][32]byte) ([]byte, error) + GetAllScalars(context.Context, uint32, string) ([]byte, error) + GetAllScalarsAt(context.Context, uint32, uint64) ([]byte, error) + PutObject(context.Context, uint32, string, string) (uint32, error) + GetObject(context.Context, uint32, string) (uint32, string, error) + InsertObject(context.Context, uint32, uint64, string) (uint32, error) + PutObjectAt(context.Context, uint32, uint64, string) (uint32, error) + GetObjectAt(context.Context, uint32, uint64) (uint32, string, error) + InsertScalar(context.Context, uint32, uint64, []byte) error + PutScalarAt(context.Context, uint32, uint64, []byte) error + GetScalarAt(context.Context, uint32, uint64) ([]byte, error) + DeleteMap(context.Context, uint32, string) error + DeleteSequence(context.Context, uint32, uint64) error + Increment(context.Context, uint32, string, int64) error + IncrementAt(context.Context, uint32, uint64, int64) error + Keys(context.Context, uint32) ([]string, error) + Length(context.Context, uint32) (uint64, error) PutText(context.Context, uint32, string) (uint32, error) GetText(context.Context, uint32, string) (uint32, error) SpliceText(context.Context, uint32, uint32, int32, string) error + UpdateText(context.Context, uint32, string) error + UpdateSpans(context.Context, uint32, []byte, []byte) error + MarkText(context.Context, uint32, uint32, uint32, string, []byte, string) error + SplitBlock(context.Context, uint32, uint32) (uint32, error) + JoinBlock(context.Context, uint32, uint32) error + ReplaceBlock(context.Context, uint32, uint32) (uint32, error) Text(context.Context, uint32) (string, error) + TextAt(context.Context, uint32, [][32]byte) (string, error) TextSpans(context.Context, uint32) ([]byte, error) + TextSpansAt(context.Context, uint32, [][32]byte) ([]byte, error) + Marks(context.Context, uint32) ([]byte, error) + MarksAt(context.Context, uint32, [][32]byte) ([]byte, error) TextCursor(context.Context, uint32, uint32) ([]byte, error) + TextCursorMoving(context.Context, uint32, uint32, bool) ([]byte, error) + TextCursorMovingAt(context.Context, uint32, uint32, bool, [][32]byte) ([]byte, error) TextCursorPosition(context.Context, uint32, []byte) (uint32, error) Commit(context.Context, string, time.Time) ([32]byte, error) + EmptyCommit(context.Context, string, time.Time) ([32]byte, error) + Rollback(context.Context) (uint64, error) Heads(context.Context) ([][32]byte, error) + HasHeads(context.Context, [][32]byte) (bool, error) + MissingDependencies(context.Context, [][32]byte) ([][32]byte, error) Merge(context.Context, []byte) ([][32]byte, error) NewSyncState(context.Context) (uint32, error) CloseSyncState(context.Context, uint32) error GenerateSyncMessage(context.Context, uint32) ([]byte, bool, error) ReceiveSyncMessage(context.Context, uint32, []byte) error + SetSyncReadOnly(context.Context, uint32, bool) error + SyncPeerReadOnly(context.Context, uint32) (bool, error) SaveSyncState(context.Context, uint32) ([]byte, error) LoadSyncState(context.Context, []byte) (uint32, error) } @@ -190,6 +240,178 @@ func LoadReference( return &Document{backend: b}, nil } +// LoadConvertingStrings loads a document with the native engine, converting +// every string scalar stored in a map or list into a text object. It mirrors +// the Rust StringMigration::ConvertToText load option. +func LoadConvertingStrings( + ctx context.Context, + data []byte, + actorID ActorID, +) (*Document, error) { + document, err := LoadPureGo(ctx, data, actorID) + if err != nil { + return nil, err + } + + if err := document.convertStringsToText(ctx); err != nil { + _ = document.Close(ctx) + + return nil, err + } + + return document, nil +} + +// LoadReferenceConvertingStrings loads a document with the reference engine and +// the string-to-text migration applied. +func LoadReferenceConvertingStrings( + ctx context.Context, + data []byte, + actorID ActorID, +) (*Document, error) { + b, err := reference.LoadConvertingStrings(ctx, data) + if err != nil { + return nil, fmt.Errorf("cannot load Automerge backend: %w", err) + } + + if err := b.SetActor(ctx, actorID[:]); err != nil { + _ = b.Close(ctx) + + return nil, fmt.Errorf("cannot assign loaded Automerge actor: %w", err) + } + + return &Document{backend: b}, nil +} + +// convertStringsToText replaces every string scalar reachable from the root in +// a map or list with a text object holding that string, then commits the +// conversion when anything changed. It backs the string-to-text load migration. +func (d *Document) convertStringsToText(ctx context.Context) error { + changed, err := convertObjectStrings(ctx, d.Root()) + if err != nil { + return fmt.Errorf("cannot migrate strings to text: %w", err) + } + + if !changed { + return nil + } + + if _, err := d.Commit(ctx, "convert strings to text", time.Unix(0, 0)); err != nil { + return fmt.Errorf("cannot commit string migration: %w", err) + } + + return nil +} + +func convertObjectStrings(ctx context.Context, object *Object) (bool, error) { + switch object.Type { + case ObjectTypeMap, ObjectTypeTable: + return convertMapStrings(ctx, object) + case ObjectTypeList: + return convertListStrings(ctx, object) + default: + return false, nil + } +} + +func convertMapStrings(ctx context.Context, object *Object) (bool, error) { + keys, err := object.Keys(ctx) + if err != nil { + return false, err + } + + changed := false + + for _, key := range keys { + if scalar, err := object.Scalar(ctx, key); err == nil { + if scalar.Type != ScalarTypeString { + continue + } + + text, err := object.CreateObject(ctx, key, ObjectTypeText) + if err != nil { + return false, err + } + + handle, err := text.Text() + if err != nil { + return false, err + } + + if err := handle.Splice(ctx, 0, 0, scalar.String); err != nil { + return false, err + } + + changed = true + + continue + } + + child, err := object.Object(ctx, key) + if err != nil { + continue + } + + childChanged, err := convertObjectStrings(ctx, child) + if err != nil { + return false, err + } + + changed = changed || childChanged + } + + return changed, nil +} + +func convertListStrings(ctx context.Context, object *Object) (bool, error) { + length, err := object.Len(ctx) + if err != nil { + return false, err + } + + changed := false + + for index := uint64(0); index < length; index++ { + if scalar, err := object.ScalarAt(ctx, index); err == nil { + if scalar.Type != ScalarTypeString { + continue + } + + text, err := object.PutObjectAt(ctx, index, ObjectTypeText) + if err != nil { + return false, err + } + + handle, err := text.Text() + if err != nil { + return false, err + } + + if err := handle.Splice(ctx, 0, 0, scalar.String); err != nil { + return false, err + } + + changed = true + + continue + } + + child, err := object.ObjectAt(ctx, index) + if err != nil { + continue + } + + childChanged, err := convertObjectStrings(ctx, child) + if err != nil { + return false, err + } + + changed = changed || childChanged + } + + return changed, nil +} + // LoadPureGo loads Automerge data using the experimental native Go engine. func LoadPureGo( ctx context.Context, @@ -244,6 +466,176 @@ func (d *Document) Save(ctx context.Context) ([]byte, error) { return data, nil } +// SaveWithOptions serializes the document, choosing whether to retain orphan +// changes (changes whose dependencies are missing). Retaining them, the default +// for Save, preserves them across a save/load round trip so they can be resolved +// once their dependencies arrive; discarding them drops them permanently. It +// mirrors the Rust SaveOptions.retain_orphans flag. +func (d *Document) SaveWithOptions( + ctx context.Context, + retainOrphans bool, +) ([]byte, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.SaveWithOptions(ctx, retainOrphans) + if err != nil { + return nil, fmt.Errorf("cannot save Automerge document: %w", err) + } + + return data, nil +} + +// Isolate pins the document to the given heads so that subsequent reads reflect +// that frontier plus writes made while isolated, and new changes branch from it. +// Isolated changes still accumulate in the full history and become visible after +// Integrate. It mirrors the Rust AutoCommit::isolate API. +func (d *Document) Isolate(ctx context.Context, heads []Hash) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + if err := d.backend.Isolate(ctx, backendHashes(heads)); err != nil { + return fmt.Errorf("cannot isolate Automerge document: %w", err) + } + + return nil +} + +// Integrate ends isolation, returning reads and writes to the full history that +// includes every isolated and merged change. It mirrors AutoCommit::integrate. +func (d *Document) Integrate(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + if err := d.backend.Integrate(ctx); err != nil { + return fmt.Errorf("cannot integrate Automerge document: %w", err) + } + + return nil +} + +// SaveNoCompress serializes the document without DEFLATE-compressing its change +// data. The default Save compresses large change chunks; this variant is useful +// for comparing compressed and uncompressed sizes. It mirrors the Rust +// AutoCommit::save_nocompress API. +func (d *Document) SaveNoCompress(ctx context.Context) ([]byte, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.SaveNoCompress(ctx) + if err != nil { + return nil, fmt.Errorf("cannot save Automerge document: %w", err) + } + + return data, nil +} + +// Stats reports aggregate document statistics. +type Stats struct { + NumChanges uint64 `json:"numChanges"` + NumOps uint64 `json:"numOps"` + NumActors uint64 `json:"numActors"` +} + +// Stats returns the number of changes, operations, and actors in the document. +func (d *Document) Stats(ctx context.Context) (Stats, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return Stats{}, ErrClosed + } + + data, err := d.backend.Stats(ctx) + if err != nil { + return Stats{}, fmt.Errorf("cannot read Automerge stats: %w", err) + } + + var stats Stats + if err := json.Unmarshal(data, &stats); err != nil { + return Stats{}, fmt.Errorf("cannot decode Automerge stats: %w", err) + } + + return stats, nil +} + +// Fork creates an independent writer with the same document history. +func (d *Document) Fork( + ctx context.Context, + actorID ActorID, +) (*Document, error) { + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return nil, ErrClosed + } + + data, err := d.backend.Save(ctx) + if err != nil { + d.mu.Unlock() + return nil, fmt.Errorf("cannot save Automerge fork source: %w", err) + } + + _, referenceBackend := d.backend.(*reference.Backend) + d.mu.Unlock() + + if referenceBackend { + return LoadReference(ctx, data, actorID) + } + + return Load(ctx, data, actorID) +} + +// SaveIncremental serializes changes since the previous save operation. +func (d *Document) SaveIncremental(ctx context.Context) ([]byte, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.SaveIncremental(ctx) + if err != nil { + return nil, fmt.Errorf("cannot save incremental Automerge changes: %w", err) + } + + return data, nil +} + +// LoadIncremental applies incrementally encoded Automerge changes. +func (d *Document) LoadIncremental(ctx context.Context, data []byte) (uint64, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return 0, ErrClosed + } + + applied, err := d.backend.LoadIncremental(ctx, data) + if err != nil { + return 0, fmt.Errorf("cannot load incremental Automerge changes: %w", err) + } + + return applied, nil +} + // PutString assigns a string at a key in the root map. func (d *Document) PutString(ctx context.Context, key, value string) error { d.mu.Lock() @@ -260,6 +652,23 @@ func (d *Document) PutString(ctx context.Context, key, value string) error { return nil } +// String returns a string value from a key in the root map. +func (d *Document) String(ctx context.Context, key string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return "", ErrClosed + } + + value, err := d.backend.GetString(ctx, rootObject, key) + if err != nil { + return "", fmt.Errorf("cannot get Automerge string: %w", err) + } + + return value, nil +} + // CreateText creates collaborative text at a key in the root map. func (d *Document) CreateText(ctx context.Context, key string) (*Text, error) { d.mu.Lock() @@ -315,6 +724,54 @@ func (d *Document) Commit( return Hash(hash), nil } +// CommitNow records pending operations using the current Unix timestamp. +func (d *Document) CommitNow(ctx context.Context, message string) (Hash, error) { + return d.Commit(ctx, message, time.Now()) +} + +// EmptyCommit records a change without document operations. +func (d *Document) EmptyCommit( + ctx context.Context, + message string, + timestamp time.Time, +) (Hash, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return Hash{}, ErrClosed + } + + hash, err := d.backend.EmptyCommit(ctx, message, timestamp) + if err != nil { + return Hash{}, fmt.Errorf("cannot commit empty Automerge change: %w", err) + } + + return Hash(hash), nil +} + +// EmptyCommitNow records an empty change using the current Unix timestamp. +func (d *Document) EmptyCommitNow(ctx context.Context, message string) (Hash, error) { + return d.EmptyCommit(ctx, message, time.Now()) +} + +// Rollback discards every operation pending in the current change. +func (d *Document) Rollback(ctx context.Context) (uint64, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return 0, ErrClosed + } + + cancelled, err := d.backend.Rollback(ctx) + if err != nil { + return 0, fmt.Errorf("cannot roll back Automerge document: %w", err) + } + + return cancelled, nil +} + // Heads returns the hashes at the document's current frontier. func (d *Document) Heads(ctx context.Context) ([]Hash, error) { d.mu.Lock() @@ -337,6 +794,84 @@ func (d *Document) Heads(ctx context.Context) ([]Hash, error) { return heads, nil } +// ReferenceBloomContains reports whether a sync Bloom filter built from the +// seed change hashes (possibly falsely) contains the target hash. It is only +// available on reference (WASM) documents and exists so parity tests can +// reproduce the upstream Bloom false-positive search deterministically; the +// native engine's V2 sync uses exact head comparison rather than Bloom filters, +// so native documents return an error. +func (d *Document) ReferenceBloomContains( + ctx context.Context, + seeds []Hash, + target Hash, +) (bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return false, ErrClosed + } + + backend, ok := d.backend.(*reference.Backend) + if !ok { + return false, fmt.Errorf( + "bloom filter membership is only available on reference documents", + ) + } + + seedArrays := make([][32]byte, len(seeds)) + for i := range seeds { + seedArrays[i] = [32]byte(seeds[i]) + } + + return backend.BloomContains(ctx, [32]byte(target), seedArrays) +} + +// HasHeads reports whether every hash exists in the document history. +func (d *Document) HasHeads(ctx context.Context, heads []Hash) (bool, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return false, ErrClosed + } + + hasHeads, err := d.backend.HasHeads(ctx, backendHashes(heads)) + if err != nil { + return false, fmt.Errorf("cannot inspect Automerge heads: %w", err) + } + + return hasHeads, nil +} + +// MissingDependencies returns unknown hashes required to reach heads. +func (d *Document) MissingDependencies( + ctx context.Context, + heads []Hash, +) ([]Hash, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + missing, err := d.backend.MissingDependencies( + ctx, + backendHashes(heads), + ) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge missing dependencies: %w", err) + } + + result := make([]Hash, len(missing)) + for i, hash := range missing { + result[i] = Hash(hash) + } + + return result, nil +} + // ChangesSince returns encoded changes not covered by heads. func (d *Document) ChangesSince( ctx context.Context, @@ -489,6 +1024,24 @@ func (t *Text) Splice(ctx context.Context, index uint32, deleteCount int32, valu return nil } +// Update replaces the text content with value using a minimal splice so that +// concurrent edits to unaffected regions merge cleanly. It mirrors the Rust +// AutoCommit::update_text and JavaScript updateText helpers. +func (t *Text) Update(ctx context.Context, value string) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + if err := t.document.backend.UpdateText(ctx, t.handle, value); err != nil { + return fmt.Errorf("cannot update Automerge text: %w", err) + } + + return nil +} + // String returns the current materialized text. func (t *Text) String(ctx context.Context) (string, error) { t.document.mu.Lock() @@ -506,6 +1059,27 @@ func (t *Text) String(ctx context.Context) (string, error) { return value, nil } +// StringAt returns text at a historical causal frontier. +func (t *Text) StringAt(ctx context.Context, heads []Hash) (string, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return "", ErrClosed + } + + value, err := t.document.backend.TextAt( + ctx, + t.handle, + backendHashes(heads), + ) + if err != nil { + return "", fmt.Errorf("cannot read historical Automerge text: %w", err) + } + + return value, nil +} + // Cursor returns a stable address for the UTF-16 position at index. func (t *Text) Cursor(ctx context.Context, index uint32) (Cursor, error) { t.document.mu.Lock() @@ -603,6 +1177,51 @@ func (s *SyncState) ReceiveMessage(ctx context.Context, message []byte) error { return nil } +// SetReadOnly controls whether incoming changes are applied by this peer. +func (s *SyncState) SetReadOnly(ctx context.Context, readOnly bool) error { + s.document.mu.Lock() + defer s.document.mu.Unlock() + + if s.document.closed { + return ErrClosed + } + + if s.closed { + return ErrSyncStateClosed + } + + if err := s.document.backend.SetSyncReadOnly( + ctx, + s.handle, + readOnly, + ); err != nil { + return fmt.Errorf("cannot set Automerge sync read-only mode: %w", err) + } + + return nil +} + +// PeerReadOnly reports whether the remote peer advertised read-only mode. +func (s *SyncState) PeerReadOnly(ctx context.Context) (bool, error) { + s.document.mu.Lock() + defer s.document.mu.Unlock() + + if s.document.closed { + return false, ErrClosed + } + + if s.closed { + return false, ErrSyncStateClosed + } + + readOnly, err := s.document.backend.SyncPeerReadOnly(ctx, s.handle) + if err != nil { + return false, fmt.Errorf("cannot get Automerge peer read-only mode: %w", err) + } + + return readOnly, nil +} + // Save serializes the peer-specific synchronization state. func (s *SyncState) Save(ctx context.Context) ([]byte, error) { s.document.mu.Lock() diff --git a/pkg/automerge/batch_insert_parity_test.go b/pkg/automerge/batch_insert_parity_test.go new file mode 100644 index 0000000000..3d7c4ea717 --- /dev/null +++ b/pkg/automerge/batch_insert_parity_test.go @@ -0,0 +1,310 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust batch-insertion tests from +// automerge 0.10 (rust/automerge/tests/batch_insert.rs) against both the native +// Go engine and the Rust/WASM reference engine, driving the public hydration +// API (PutValue, PutValueAt, SpliceValues) that mirrors batch_create_object. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func hydratedMap(pairs map[string]automerge.Value) automerge.Value { + return automerge.Value{Type: automerge.ValueTypeMap, Map: pairs} +} + +func hydratedList(values ...automerge.Value) automerge.Value { + return automerge.Value{Type: automerge.ValueTypeList, List: values} +} + +// TestRustBatch_MergesCorrectly reproduces batch_insert_merges_correctly. +func TestRustBatch_MergesCorrectly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + require.NoError(t, doc1.Root().PutValue( + ctx, + "obj1", + hydratedMap(map[string]automerge.Value{"from": hydratedString("doc1")}), + )) + _, err = doc1.Commit(ctx, "obj1", commitTime) + require.NoError(t, err) + + doc2, err := doc1.Fork(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + require.NoError(t, doc2.Root().PutValue( + ctx, + "obj2", + hydratedMap(map[string]automerge.Value{"from": hydratedString("doc2")}), + )) + _, err = doc2.Commit(ctx, "obj2", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + obj1, err := doc1.Root().Object(ctx, "obj1") + require.NoError(t, err) + value, err := obj1.Scalar(ctx, "from") + require.NoError(t, err) + assert.Equal(t, "doc1", value.String) + + obj2, err := doc1.Root().Object(ctx, "obj2") + require.NoError(t, err) + value, err = obj2.Scalar(ctx, "from") + require.NoError(t, err) + assert.Equal(t, "doc2", value.String) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustBatch_MultipleInserts reproduces multiple_batch_inserts. +func TestRustBatch_MultipleInserts(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + + require.NoError(t, doc.Root().PutValue( + ctx, + "first", + hydratedMap(map[string]automerge.Value{"a": hydratedInt(1)}), + )) + require.NoError(t, doc.Root().PutValue( + ctx, + "second", + hydratedMap(map[string]automerge.Value{"b": hydratedInt(2)}), + )) + require.NoError(t, doc.Root().PutValue( + ctx, + "third", + hydratedMap(map[string]automerge.Value{"c": hydratedInt(3)}), + )) + _, err = doc.Commit(ctx, "batches", commitTime) + require.NoError(t, err) + + for key, field := range map[string]struct { + name string + value int64 + }{ + "first": {"a", 1}, + "second": {"b", 2}, + "third": {"c", 3}, + } { + object, err := doc.Root().Object(ctx, key) + require.NoError(t, err) + value, err := object.Scalar(ctx, field.name) + require.NoError(t, err) + assert.Equal(t, field.value, value.Int) + } + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustBatch_InsertIntoExistingMap reproduces batch_insert_into_existing_map. +func TestRustBatch_InsertIntoExistingMap(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + + parent, err := doc.Root().CreateObject(ctx, "parent", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, parent.PutScalar( + ctx, + "existing", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + require.NoError(t, parent.PutValue( + ctx, + "child", + hydratedMap(map[string]automerge.Value{ + "x": hydratedInt(1), + "y": hydratedInt(2), + }), + )) + _, err = doc.Commit(ctx, "batch", commitTime) + require.NoError(t, err) + + existing, err := parent.Scalar(ctx, "existing") + require.NoError(t, err) + assert.Equal(t, "value", existing.String) + + child, err := parent.Object(ctx, "child") + require.NoError(t, err) + x, err := child.Scalar(ctx, "x") + require.NoError(t, err) + assert.Equal(t, int64(1), x.Int) + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustBatch_PutOverwriteWithNestedStructure reproduces +// batch_put_overwrite_with_nested_structure. +func TestRustBatch_PutOverwriteWithNestedStructure(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + + list, err := doc.Root().CreateObject(ctx, "items", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertValues(ctx, 0, []automerge.Value{ + hydratedString("placeholder"), + hydratedString("keep"), + })) + + require.NoError(t, list.PutValueAt(ctx, 0, hydratedMap(map[string]automerge.Value{ + "name": hydratedString("complex"), + "children": hydratedList( + hydratedMap(map[string]automerge.Value{"id": hydratedInt(1)}), + hydratedMap(map[string]automerge.Value{"id": hydratedInt(2)}), + ), + }))) + _, err = doc.Commit(ctx, "overwrite", commitTime) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), length) + + object, err := list.ObjectAt(ctx, 0) + require.NoError(t, err) + name, err := object.Scalar(ctx, "name") + require.NoError(t, err) + assert.Equal(t, "complex", name.String) + + children, err := object.Object(ctx, "children") + require.NoError(t, err) + childrenLength, err := children.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), childrenLength) + + firstChild, err := children.ObjectAt(ctx, 0) + require.NoError(t, err) + id, err := firstChild.Scalar(ctx, "id") + require.NoError(t, err) + assert.Equal(t, int64(1), id.Int) + + keep, err := list.ScalarAt(ctx, 1) + require.NoError(t, err) + assert.Equal(t, "keep", keep.String) + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustBatch_SpliceMergesCorrectly reproduces splice_merges_correctly. +func TestRustBatch_SpliceMergesCorrectly(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + list1, err := doc1.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list1.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "shared"}, + )) + _, err = doc1.Commit(ctx, "shared", commitTime) + require.NoError(t, err) + + doc2, err := doc1.Fork(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + require.NoError(t, list1.SpliceValues(ctx, 1, 0, []automerge.Value{ + hydratedMap(map[string]automerge.Value{"from": hydratedString("doc1")}), + })) + _, err = doc1.Commit(ctx, "doc1", commitTime.Add(time.Second)) + require.NoError(t, err) + + list2, err := doc2.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, list2.SpliceValues(ctx, 1, 0, []automerge.Value{ + hydratedMap(map[string]automerge.Value{"from": hydratedString("doc2")}), + })) + _, err = doc2.Commit(ctx, "doc2", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + length, err := list1.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(3), length) + + first, err := list1.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, "shared", first.String) + + results[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, results["reference"], results["native"]) +} diff --git a/pkg/automerge/batch_insert_patch_parity_test.go b/pkg/automerge/batch_insert_patch_parity_test.go new file mode 100644 index 0000000000..5857456224 --- /dev/null +++ b/pkg/automerge/batch_insert_patch_parity_test.go @@ -0,0 +1,174 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the patch-generating batch-insert scenarios +// from upstream Rust automerge 0.10 (rust/automerge/tests/batch_insert.rs), +// asserting the native Go and Rust/WASM reference engines produce identical +// patch streams for a hydrated batch insertion. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func intValue(value int64) automerge.Value { + return automerge.Value{ + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{Type: automerge.ScalarTypeInt, Int: value}, + } +} + +func stringValue(value string) automerge.Value { + return automerge.Value{ + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{Type: automerge.ScalarTypeString, String: value}, + } +} + +// TestRustBatchInsert_GeneratesPatches reproduces batch_insert_generates_patches. +func TestRustBatchInsert_GeneratesPatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + value := automerge.Value{ + Type: automerge.ValueTypeMap, + Map: map[string]automerge.Value{ + "name": stringValue("test"), + "items": {Type: automerge.ValueTypeList, List: []automerge.Value{intValue(1), intValue(2)}}, + }, + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, document.Root().PutValue(ctx, "data", value)) + _, err = document.Commit(ctx, "batch", commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + assert.NotEmpty(t, reference) + + hasData := false + for _, patch := range reference { + if patch.Action == automerge.PatchPutMap && patch.Key == "data" { + hasData = true + } + } + + assert.True(t, hasData, "expected a put_map patch for data") + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustBatchInsert_TextGeneratesSplicePatch reproduces +// batch_insert_text_generates_splice_patch. +func TestRustBatchInsert_TextGeneratesSplicePatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + value := automerge.Value{ + Type: automerge.ValueTypeMap, + Map: map[string]automerge.Value{ + "greeting": {Type: automerge.ValueTypeText, Text: "hi"}, + }, + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, document.Root().PutValue(ctx, "data", value)) + _, err = document.Commit(ctx, "batch", commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + hasSplice := false + for _, patch := range result["reference"] { + if patch.Action == automerge.PatchSpliceText { + hasSplice = true + } + } + + assert.True(t, hasSplice, "expected a splice_text patch") + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustBatchInit_MapGeneratesPatches reproduces batch_init_map_generates_patches. +func TestRustBatchInit_MapGeneratesPatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + root := map[string]automerge.Value{ + "name": stringValue("test"), + "items": {Type: automerge.ValueTypeList, List: []automerge.Value{intValue(1), intValue(2)}}, + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutMap(ctx, root)) + _, err = document.Commit(ctx, "init", commitTime) + require.NoError(t, err) + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, nil, heads) + require.NoError(t, err) + result[engine.name] = patches + } + + hasName := false + for _, patch := range result["reference"] { + if patch.Action == automerge.PatchPutMap && patch.Key == "name" { + hasName = true + } + } + + assert.True(t, hasName, "expected a put_map patch for name") + assert.Equal(t, result["reference"], result["native"]) +} diff --git a/pkg/automerge/benchmark_test.go b/pkg/automerge/benchmark_test.go new file mode 100644 index 0000000000..43fc5a9ed9 --- /dev/null +++ b/pkg/automerge/benchmark_test.go @@ -0,0 +1,429 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "fmt" + "strconv" + "testing" + + "go.probo.inc/probo/pkg/automerge" +) + +type benchmarkFactory func( + context.Context, + automerge.ActorID, +) (*automerge.Document, error) + +func BenchmarkDocumentCreation(b *testing.B) { + benchmarkEngines(b, func(b *testing.B, factory benchmarkFactory) { + ctx := context.Background() + + b.ReportAllocs() + + for b.Loop() { + document, err := factory(ctx, actor(200)) + if err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + } + }) +} + +func BenchmarkMapMutations(b *testing.B) { + for _, size := range []int{100, 1_000} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + benchmarkEngines(b, func(b *testing.B, factory benchmarkFactory) { + ctx := context.Background() + + b.ReportAllocs() + + for b.Loop() { + document, err := factory(ctx, actor(201)) + if err != nil { + b.Fatal(err) + } + + values, err := document.Root().CreateObject( + ctx, + "values", + automerge.ObjectTypeMap, + ) + if err != nil { + b.Fatal(err) + } + + for index := range size { + if err := values.PutScalar( + ctx, + strconv.Itoa(index), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: int64(index), + }, + ); err != nil { + b.Fatal(err) + } + } + + if _, err := document.Commit( + ctx, + "map mutations", + commitTime, + ); err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + } + }) + }) + } +} + +func BenchmarkTextTyping(b *testing.B) { + for _, size := range []int{100, 1_000} { + b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) { + benchmarkEngines(b, func(b *testing.B, factory benchmarkFactory) { + ctx := context.Background() + + b.ReportAllocs() + + for b.Loop() { + document, err := factory(ctx, actor(202)) + if err != nil { + b.Fatal(err) + } + + text, err := document.CreateText(ctx, "body") + if err != nil { + b.Fatal(err) + } + + for index := range size { + if err := text.Splice( + ctx, + uint32(index), + 0, + "x", + ); err != nil { + b.Fatal(err) + } + } + + if _, err := document.Commit( + ctx, + "text typing", + commitTime, + ); err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + } + }) + }) + } +} + +func BenchmarkLoad(b *testing.B) { + ctx := context.Background() + data := benchmarkDocument(b, 10_000) + + b.Run("native", func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + document, err := automerge.Load(ctx, data, actor(203)) + if err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + } + }) + b.Run("reference", func(b *testing.B) { + warmReference(b) + b.ReportAllocs() + + for b.Loop() { + document, err := automerge.LoadReference(ctx, data, actor(203)) + if err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + } + }) +} + +func BenchmarkSave(b *testing.B) { + ctx := context.Background() + data := benchmarkDocument(b, 10_000) + + b.Run("native", func(b *testing.B) { + document, err := automerge.Load(ctx, data, actor(204)) + if err != nil { + b.Fatal(err) + } + + defer func() { _ = document.Close(ctx) }() + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + if _, err := document.Save(ctx); err != nil { + b.Fatal(err) + } + } + }) + b.Run("reference", func(b *testing.B) { + warmReference(b) + + document, err := automerge.LoadReference(ctx, data, actor(204)) + if err != nil { + b.Fatal(err) + } + + defer func() { _ = document.Close(ctx) }() + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + if _, err := document.Save(ctx); err != nil { + b.Fatal(err) + } + } + }) +} + +func BenchmarkInitialSync(b *testing.B) { + combinations := []struct { + name string + source benchmarkFactory + target benchmarkFactory + }{ + { + name: "native-to-native", + source: automerge.New, + target: automerge.New, + }, + { + name: "native-to-reference", + source: automerge.New, + target: automerge.NewReference, + }, + { + name: "reference-to-native", + source: automerge.NewReference, + target: automerge.New, + }, + } + + for _, combination := range combinations { + b.Run(combination.name, func(b *testing.B) { + ctx := context.Background() + + warmReference(b) + b.ReportAllocs() + + for b.Loop() { + source, err := combination.source(ctx, actor(205)) + if err != nil { + b.Fatal(err) + } + + text, err := source.CreateText(ctx, "body") + if err != nil { + b.Fatal(err) + } + + if err := text.Splice(ctx, 0, 0, benchmarkText(1_000)); err != nil { + b.Fatal(err) + } + + if _, err := source.Commit(ctx, "sync source", commitTime); err != nil { + b.Fatal(err) + } + + target, err := combination.target(ctx, actor(206)) + if err != nil { + b.Fatal(err) + } + + sourceState, err := source.NewSyncState(ctx) + if err != nil { + b.Fatal(err) + } + + targetState, err := target.NewSyncState(ctx) + if err != nil { + b.Fatal(err) + } + + if err := benchmarkSynchronize( + ctx, + sourceState, + targetState, + ); err != nil { + b.Fatal(err) + } + + _ = sourceState.Close(ctx) + _ = targetState.Close(ctx) + _ = source.Close(ctx) + _ = target.Close(ctx) + } + }) + } +} + +func benchmarkEngines( + b *testing.B, + benchmark func(*testing.B, benchmarkFactory), +) { + b.Helper() + + b.Run("native", func(b *testing.B) { + benchmark(b, automerge.New) + }) + b.Run("reference", func(b *testing.B) { + warmReference(b) + benchmark(b, automerge.NewReference) + }) +} + +func benchmarkDocument(b *testing.B, size int) []byte { + b.Helper() + + ctx := context.Background() + + document, err := automerge.New(ctx, actor(207)) + if err != nil { + b.Fatal(err) + } + + defer func() { _ = document.Close(ctx) }() + + text, err := document.CreateText(ctx, "body") + if err != nil { + b.Fatal(err) + } + + if err := text.Splice(ctx, 0, 0, benchmarkText(size)); err != nil { + b.Fatal(err) + } + + if _, err := document.Commit(ctx, "benchmark fixture", commitTime); err != nil { + b.Fatal(err) + } + + data, err := document.Save(ctx) + if err != nil { + b.Fatal(err) + } + + return data +} + +func warmReference(b *testing.B) { + b.Helper() + b.StopTimer() + + ctx := context.Background() + + document, err := automerge.NewReference(ctx, actor(208)) + if err != nil { + b.Fatal(err) + } + + if err := document.Close(ctx); err != nil { + b.Fatal(err) + } + + b.StartTimer() +} + +func benchmarkText(size int) string { + value := make([]byte, size) + for index := range value { + value[index] = byte('a' + index%26) + } + + return string(value) +} + +func benchmarkSynchronize( + ctx context.Context, + left *automerge.SyncState, + right *automerge.SyncState, +) error { + for range 100 { + progressed := false + + message, ok, err := left.GenerateMessage(ctx) + if err != nil { + return err + } + + if ok { + if err := right.ReceiveMessage(ctx, message); err != nil { + return err + } + + progressed = true + } + + message, ok, err = right.GenerateMessage(ctx) + if err != nil { + return err + } + + if ok { + if err := left.ReceiveMessage(ctx, message); err != nil { + return err + } + + progressed = true + } + + if !progressed { + return nil + } + } + + return fmt.Errorf("sync did not quiesce") +} diff --git a/pkg/automerge/block_spans_parity_test.go b/pkg/automerge/block_spans_parity_test.go new file mode 100644 index 0000000000..6e88755db2 --- /dev/null +++ b/pkg/automerge/block_spans_parity_test.go @@ -0,0 +1,502 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the block-valued update_spans scenarios from +// upstream Rust automerge 0.10 (rust/automerge/tests/block_tests.rs and +// diff_marks.rs). Each runs update_spans identically on the native Go engine and +// the Rust/WASM reference engine and asserts their materialized spans agree. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func blockSpan(attributes map[string]any) automerge.SpanInput { + if attributes == nil { + attributes = map[string]any{} + } + + return automerge.SpanInput{Block: attributes} +} + +func textSpan(text string, markPairs ...any) automerge.SpanInput { + return automerge.SpanInput{Text: text, Marks: marks(markPairs...)} +} + +func emptyAttrs() map[string]any { + return map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}} +} + +type blockSpanScenario struct { + name string + initial []automerge.SpanInput + target []automerge.SpanInput + config automerge.UpdateSpansConfig + post func(ctx context.Context, t *testing.T, text *automerge.Text) +} + +func TestRustBlockSpans(t *testing.T) { + t.Parallel() + + defaultConfig := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + + scenarios := []blockSpanScenario{ + { + name: "update_blocks_change_block_properties", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("item 1"), + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("item 2"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("item 1"), + blockSpan(map[string]any{"type": "unordered-list-item", "parents": []any{"ordered-list-item"}, "attrs": map[string]any{"key": 1}}), + textSpan("item 2"), + }, + config: defaultConfig, + }, + { + name: "update_blocks_updates_text", + initial: []automerge.SpanInput{ + blockSpan(emptyAttrs()), + textSpan("first thing"), + blockSpan(emptyAttrs()), + textSpan("second thing"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("the first thing"), + blockSpan(map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("the things are done"), + }, + config: defaultConfig, + }, + { + name: "update_blocks_updates_marks", + initial: []automerge.SpanInput{ + textSpan("onetwo"), + blockSpan(emptyAttrs()), + textSpan("threefour"), + }, + target: []automerge.SpanInput{ + textSpan("one"), + textSpan("two", "bold", markBool()), + blockSpan(emptyAttrs()), + textSpan("three"), + textSpan("four", "italic", markBool()), + }, + config: defaultConfig, + }, + { + name: "update_blocks_updates_text_and_blocks_at_once", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("hello world"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "unordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("goodbye world"), + }, + config: defaultConfig, + }, + { + name: "update_spans_delete_attribute", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{"div"}}), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}}), + }, + config: defaultConfig, + }, + { + name: "update_spans_diffs_marks", + initial: []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" world"), + }, + target: []automerge.SpanInput{ + textSpan("hello", "italic", markBool()), + textSpan(" "), + textSpan("world", "bold", markBool(), "italic", markBool()), + }, + config: defaultConfig, + }, + { + name: "update_spans_uses_expand_config", + initial: nil, + target: []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" world"), + }, + config: automerge.UpdateSpansConfig{ + DefaultExpand: automerge.MarkExpandNone, + PerMarkExpands: map[string]automerge.MarkExpand{"bold": automerge.MarkExpandAfter}, + }, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 5, 0, "!")) + require.NoError(t, text.Splice(ctx, 0, 0, "Oh ")) + }, + }, + { + name: "mark_spans_across_block", + initial: nil, + target: []automerge.SpanInput{ + textSpan("bold", "bold", markBool()), + blockSpan(nil), + textSpan("text", "bold", markBool()), + }, + config: defaultConfig, + }, + { + name: "mark_ends_at_block_boundary", + initial: nil, + target: []automerge.SpanInput{ + textSpan("bold", "bold", markBool()), + blockSpan(nil), + textSpan("text"), + }, + config: defaultConfig, + }, + { + name: "block_properties_change_with_marks", + initial: []automerge.SpanInput{ + blockSpan(emptyAttrs()), + textSpan("marked text"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "level": 1}), + textSpan("marked", "bold", markBool()), + textSpan(" text"), + }, + config: defaultConfig, + }, + { + name: "block_with_marked_content", + initial: nil, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "heading", "level": 1}), + textSpan("Chapter "), + textSpan("One", "emphasis", markBool()), + blockSpan(map[string]any{"type": "paragraph"}), + textSpan("This is the "), + textSpan("first", "bold", markBool()), + textSpan(" chapter."), + }, + config: defaultConfig, + }, + { + name: "update_spans_with_only_blocks", + initial: []automerge.SpanInput{ + blockSpan(emptyAttrs()), + textSpan("text"), + blockSpan(emptyAttrs()), + textSpan("more"), + }, + target: []automerge.SpanInput{ + blockSpan(nil), + blockSpan(nil), + }, + config: defaultConfig, + }, + { + name: "marks_survive_block_updates", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "p"}), + textSpan("marked", "bold", markBool()), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "h1", "level": 1}), + textSpan("marked", "bold", markBool()), + }, + config: defaultConfig, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + if scenario.initial != nil { + require.NoError(t, text.UpdateSpans(ctx, scenario.initial, scenario.config)) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + } + + require.NoError(t, text.UpdateSpans(ctx, scenario.target, scenario.config)) + _, err = document.Commit(ctx, "target", commitTime) + require.NoError(t, err) + + if scenario.post != nil { + scenario.post(ctx, t, text) + _, err = document.Commit(ctx, "post", commitTime) + require.NoError(t, err) + } + + spans, err := text.Spans(ctx) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) + }) + } +} + +// TestRustText_InsertionsAfterNoexpandSpans reproduces +// insertions_after_noexpand_spans_are_not_marked: text appended after a block, +// with no expanding mark in scope, is reported by a diff as an unmarked splice. +func TestRustText_InsertionsAfterNoexpandSpans(t *testing.T) { + t.Parallel() + + ctx := context.Background() + config := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandNone} + heading := map[string]any{"type": "heading", "parents": []any{}, "attrs": map[string]any{}} + paragraph := map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}} + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "") + + spans := []automerge.SpanInput{ + blockSpan(heading), + textSpan("Heading"), + blockSpan(paragraph), + textSpan("a"), + blockSpan(paragraph), + } + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + _, err := document.Commit(ctx, "spans", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 11, 0, "a")) + after, err := document.Commit(ctx, "append", commitTime.Add(time.Second)) + require.NoError(t, err) + + patches, err := document.Diff(ctx, before, []automerge.Hash{after}) + require.NoError(t, err) + result[engine.name] = patches + } + + require.Len(t, result["reference"], 1) + assert.Equal(t, automerge.PatchSpliceText, result["reference"][0].Action) + assert.Empty(t, result["reference"][0].Marks) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustBlock_MarksOnSpansRespectHeads reproduces marks_on_spans_respect_heads: +// spans_at reports the marks active at a historical frontier, excluding marks +// added afterward. +func TestRustBlock_MarksOnSpansRespectHeads(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark(ctx, 0, 5, "bold", markBool(), automerge.MarkExpandAfter)) + heads, err := document.Commit(ctx, "bold", commitTime) + require.NoError(t, err) + + require.NoError(t, text.Mark(ctx, 5, 11, "italic", markBool(), automerge.MarkExpandAfter)) + _, err = document.Commit(ctx, "italic", commitTime.Add(time.Second)) + require.NoError(t, err) + + spans, err := text.SpansAt(ctx, []automerge.Hash{heads}) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 2) + assert.Equal(t, "hello", result["native"][0].Text) + assert.Equal(t, map[string]any{"bold": true}, result["native"][0].Marks) + assert.Equal(t, " world", result["native"][1].Text) +} + +// TestRustBlock_DiffEmitsBlockUpdates reproduces diff_emits_block_updates: a diff +// from the empty frontier inserts the block and materializes its nested parents +// list. +func TestRustBlock_DiffEmitsBlockUpdates(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + block, err := text.SplitBlock(ctx, 0) + require.NoError(t, err) + _, err = block.CreateObject(ctx, "parents", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = document.Commit(ctx, "block", commitTime) + require.NoError(t, err) + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, nil, heads) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + require.NotEmpty(t, reference) + + hasBlockInsert := false + hasParents := false + + for _, patch := range reference { + if patch.Action == automerge.PatchInsert && len(patch.Values) == 1 && + patch.Values[0].Value.Object == automerge.ObjectTypeMap { + hasBlockInsert = true + } + + if patch.Action == automerge.PatchPutMap && patch.Key == "parents" { + hasParents = true + } + } + + assert.True(t, hasBlockInsert, "expected a block insert patch") + assert.True(t, hasParents, "expected a parents put_map patch") + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustBlock_MergeProducesBlockInsertionDiffs reproduces +// merge_produces_block_insertion_diffs: merging a peer that inserted a block +// yields a block insertion patch. +func TestRustBlock_MergeProducesBlockInsertionDiffs(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, other) + + _, err = text.SplitBlock(ctx, 0) + require.NoError(t, err) + _, err = document.Commit(ctx, "block", commitTime.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, other.UpdateDiffCursor(ctx)) + before, err := other.Heads(ctx) + require.NoError(t, err) + _, err = other.Merge(ctx, document) + require.NoError(t, err) + after, err := other.Heads(ctx) + require.NoError(t, err) + + patches, err := other.Diff(ctx, before, after) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + require.NotEmpty(t, reference) + assert.Equal(t, automerge.PatchInsert, reference[0].Action) + require.Len(t, reference[0].Values, 1) + assert.Equal(t, automerge.ObjectTypeMap, reference[0].Values[0].Value.Object) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustBlockSpans_Noop reproduces update_blocks_noop: re-applying the current +// spans through the diff cursor produces no patches. +func TestRustBlockSpans_Noop(t *testing.T) { + t.Parallel() + + ctx := context.Background() + config := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + + spans := []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("item 1"), + } + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + assert.Empty(t, patches) + }) + } +} diff --git a/pkg/automerge/compressed_save_parity_test.go b/pkg/automerge/compressed_save_parity_test.go new file mode 100644 index 0000000000..67f4be19bd --- /dev/null +++ b/pkg/automerge/compressed_save_parity_test.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file reproduces test_compressed_doc_cols (rust/automerge/tests/test.rs): +// a document large enough to trigger DEFLATE compression must save smaller with +// compression than without, and the compressed save must load back to the same +// value on both engines. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestRustTest_CompressedDocCols(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const items = 200 + + values := make([]automerge.Value, items) + for i := range values { + values[i] = automerge.Value{ + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{Type: automerge.ScalarTypeUint, Uint: uint64(i)}, + } + } + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + document, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutValue(ctx, "list", automerge.Value{ + Type: automerge.ValueTypeList, + List: values, + })) + + _, err = document.Commit(ctx, "list", commitTime) + require.NoError(t, err) + + uncompressed, err := document.SaveNoCompress(ctx) + require.NoError(t, err) + + compressed, err := document.Save(ctx) + require.NoError(t, err) + + assert.Less(t, len(compressed), len(uncompressed), + "compressed save should be smaller than uncompressed") + + loaded, err := engine.load(ctx, compressed, actor(0x02)) + require.NoError(t, err) + closeDocument(t, loaded) + + list, err := loaded.Root().Object(ctx, "list") + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + require.Equal(t, uint64(items), length) + + for i := 0; i < items; i++ { + value, err := list.ScalarAt(ctx, uint64(i)) + require.NoError(t, err) + assert.Equal(t, uint64(i), value.Uint) + } + }) + } +} diff --git a/pkg/automerge/conformance_test.go b/pkg/automerge/conformance_test.go index b9a9e1c240..fb9698913e 100644 --- a/pkg/automerge/conformance_test.go +++ b/pkg/automerge/conformance_test.go @@ -29,6 +29,7 @@ import ( "os" "os/exec" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,21 +40,23 @@ import ( type ( oracleRequest struct { - Action string `json:"action"` - Actor string `json:"actor,omitempty"` - ActorB string `json:"actorB,omitempty"` - ActorC string `json:"actorC,omitempty"` - Change string `json:"change,omitempty"` - Document string `json:"document,omitempty"` - Message string `json:"message,omitempty"` - Text string `json:"text,omitempty"` - Timestamp int64 `json:"timestamp,omitempty"` + Action string `json:"action"` + Actor string `json:"actor,omitempty"` + ActorB string `json:"actorB,omitempty"` + ActorC string `json:"actorC,omitempty"` + Change string `json:"change,omitempty"` + Document string `json:"document,omitempty"` + Message string `json:"message,omitempty"` + Scenario json.RawMessage `json:"scenario,omitempty"` + Text string `json:"text,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` } oracleResponse struct { Body string `json:"body"` Change string `json:"change"` Changes []string `json:"changes"` + Data any `json:"data"` Document string `json:"document"` Heads []string `json:"heads"` Message string `json:"message"` @@ -83,6 +86,73 @@ func runOracle(t *testing.T, request oracleRequest) oracleResponse { return response } +func TestConformance_DatesFlowBetweenDocuments(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + sourceActor := actor(20) + created := runOracle( + t, + oracleRequest{ + Action: "createTimestamps", + Actor: hex.EncodeToString(sourceActor[:]), + Message: "dates", + Timestamp: commitTime.Unix(), + }, + ) + + createdData, ok := created.Data.(map[string]any) + require.True(t, ok) + iso, ok := createdData["iso"].(string) + require.True(t, ok) + + source, err := base64.StdEncoding.DecodeString(created.Document) + require.NoError(t, err) + sourceDocument, err := automerge.Load(ctx, source, actor(21)) + require.NoError(t, err) + closeDocument(t, sourceDocument) + + when, err := sourceDocument.Root().Scalar(ctx, "when") + require.NoError(t, err) + require.Equal(t, automerge.ScalarTypeTimestamp, when.Type) + + list, err := sourceDocument.Root().Object(ctx, "list") + require.NoError(t, err) + listWhen, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + require.Equal(t, automerge.ScalarTypeTimestamp, listWhen.Type) + require.Equal(t, when.Int, listWhen.Int) + + // Reuse the timestamps read from the source document in a new document. + target, err := automerge.New(ctx, actor(22)) + require.NoError(t, err) + closeDocument(t, target) + require.NoError(t, target.Root().PutScalar(ctx, "when", when)) + targetList, err := target.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, targetList.InsertScalar(ctx, 0, listWhen)) + _, err = target.Commit(ctx, "reuse dates", commitTime.Add(time.Second)) + require.NoError(t, err) + saved, err := target.Save(ctx) + require.NoError(t, err) + + read := runOracle( + t, + oracleRequest{ + Action: "readTimestamps", + Document: base64.StdEncoding.EncodeToString(saved), + }, + ) + + readData, ok := read.Data.(map[string]any) + require.True(t, ok) + assert.Equal(t, true, readData["whenIsDate"]) + assert.Equal(t, iso, readData["whenISO"]) + assert.Equal(t, true, readData["listIsDate"]) + assert.Equal(t, iso, readData["listISO"]) +} + func TestConformance_JavaScriptLoadsGoDocument(t *testing.T) { t.Parallel() @@ -111,6 +181,40 @@ func TestConformance_JavaScriptLoadsGoDocument(t *testing.T) { assert.Equal(t, []string{hash.String()}, response.Heads) } +func TestConformance_JavaScriptPreservesGoChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, document) + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "ABC")) + hash, err := document.Commit(ctx, "Create in Go", commitTime) + require.NoError(t, err) + changes, err := document.ChangesSince(ctx, nil) + require.NoError(t, err) + require.Len(t, changes, 1) + + data, err := document.Save(ctx) + require.NoError(t, err) + + response := runOracle( + t, + oracleRequest{ + Action: "inspectChanges", + Document: base64.StdEncoding.EncodeToString(data), + }, + ) + + require.Equal(t, []string{hash.String()}, response.Heads) + require.Len(t, response.Changes, 1) + forwarded, err := base64.StdEncoding.DecodeString(response.Changes[0]) + require.NoError(t, err) + assert.Equal(t, changes[0].Bytes, forwarded) +} + func TestConformance_GoLoadsJavaScriptDocument(t *testing.T) { t.Parallel() @@ -152,6 +256,37 @@ func TestConformance_GoLoadsJavaScriptDocument(t *testing.T) { assert.Equal(t, "Hello from JavaScript πŸ˜€", nativeText) } +func TestConformance_NativePreservesJavaScriptDataModel(t *testing.T) { + t.Parallel() + + actorID := actor(15) + created := runOracle( + t, + oracleRequest{ + Action: "createDataModel", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(created.Document) + require.NoError(t, err) + + document, err := automerge.Load(context.Background(), data, actor(16)) + require.NoError(t, err) + closeDocument(t, document) + saved, err := document.Save(context.Background()) + require.NoError(t, err) + + inspected := runOracle( + t, + oracleRequest{ + Action: "inspectDataModel", + Document: base64.StdEncoding.EncodeToString(saved), + }, + ) + assert.Equal(t, created.Data, inspected.Data) + assert.Equal(t, created.Heads, inspected.Heads) +} + func TestConformance_GoReadsJavaScriptRichTextSpans(t *testing.T) { t.Parallel() @@ -262,6 +397,35 @@ func TestConformance_NativeParsesJavaScriptChange(t *testing.T) { assert.Equal(t, inspection.Heads[0], change.Hash.String()) } +func TestConformance_NativeParsesJavaScriptEmptyChange(t *testing.T) { + t.Parallel() + + actorID := actor(46) + response := runOracle( + t, + oracleRequest{ + Action: "createEmptyChange", + Actor: hex.EncodeToString(actorID[:]), + Message: "Empty", + Timestamp: 12_345, + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Change) + require.NoError(t, err) + decoded, err := native.Decode(data) + require.NoError(t, err) + require.Len(t, decoded.Changes, 1) + change := decoded.Changes[0] + assert.Equal(t, uint64(1), change.Sequence) + assert.Equal(t, uint64(1), change.StartOp) + assert.Equal(t, uint64(0), change.MaxOp) + assert.Equal(t, int64(12_345), change.Time) + assert.Equal(t, "Empty", change.Message) + assert.Empty(t, change.Operations) + require.NotNil(t, change.Hash) + assert.Equal(t, response.Heads[0], change.Hash.String()) +} + func TestConformance_NativeConcurrentChangesConverge(t *testing.T) { t.Parallel() @@ -411,6 +575,129 @@ func TestConformance_NativeComplexRichTextSpans(t *testing.T) { assert.Equal(t, referenceSpans, nativeSpans) } +func TestConformance_NativeBoundaryMarks(t *testing.T) { + t.Parallel() + + actorID := actor(37) + response := runOracle( + t, + oracleRequest{ + Action: "createBoundaryMarks", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference( + context.Background(), + data, + actor(38), + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(context.Background(), "body") + require.NoError(t, err) + referenceSpans, err := referenceText.Spans(context.Background()) + require.NoError(t, err) + + nativeDocument, err := automerge.Load( + context.Background(), + data, + actor(39), + ) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(context.Background(), "body") + require.NoError(t, err) + nativeSpans, err := nativeText.Spans(context.Background()) + require.NoError(t, err) + + assert.Equal(t, referenceSpans, nativeSpans) +} + +func TestConformance_NativeSplitMarks(t *testing.T) { + t.Parallel() + + actorID := actor(40) + response := runOracle( + t, + oracleRequest{ + Action: "createSplitMarks", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference( + context.Background(), + data, + actor(41), + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(context.Background(), "body") + require.NoError(t, err) + referenceSpans, err := referenceText.Spans(context.Background()) + require.NoError(t, err) + + nativeDocument, err := automerge.Load( + context.Background(), + data, + actor(42), + ) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(context.Background(), "body") + require.NoError(t, err) + nativeSpans, err := nativeText.Spans(context.Background()) + require.NoError(t, err) + + assert.Equal(t, referenceSpans, nativeSpans) +} + +func TestConformance_NativeUnicodeMarks(t *testing.T) { + t.Parallel() + + actorID := actor(43) + response := runOracle( + t, + oracleRequest{ + Action: "createUnicodeMarks", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference( + context.Background(), + data, + actor(44), + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(context.Background(), "body") + require.NoError(t, err) + referenceSpans, err := referenceText.Spans(context.Background()) + require.NoError(t, err) + + nativeDocument, err := automerge.Load( + context.Background(), + data, + actor(45), + ) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(context.Background(), "body") + require.NoError(t, err) + nativeSpans, err := nativeText.Spans(context.Background()) + require.NoError(t, err) + + assert.Equal(t, referenceSpans, nativeSpans) +} + func TestConformance_NativeTableRichTextSpans(t *testing.T) { t.Parallel() diff --git a/pkg/automerge/convert_string_to_text_parity_test.go b/pkg/automerge/convert_string_to_text_parity_test.go new file mode 100644 index 0000000000..1c99067477 --- /dev/null +++ b/pkg/automerge/convert_string_to_text_parity_test.go @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the string-to-text load migration from +// upstream Rust automerge 0.10 (rust/automerge/tests/convert_string_to_text.rs), +// asserting the native Go and Rust/WASM reference engines both convert string +// scalars into text objects when loading with the migration enabled. + +package automerge_test + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestRustCurrentState_LoadChanges reproduces test_load_changes: loading a stored +// document and materializing its current state yields a single put of the +// counter's summed value. +func TestRustCurrentState_LoadChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + data, err := os.ReadFile("testdata/fixtures/counter_value_is_ok.automerge") + require.NoError(t, err) + + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.load(ctx, data, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + patches, err := document.CurrentState(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + require.Len(t, reference, 1) + assert.Equal(t, automerge.PatchPutMap, reference[0].Action) + assert.Equal(t, "a", reference[0].Key) + require.NotNil(t, reference[0].Value.Scalar) + assert.Equal(t, automerge.ScalarTypeCounter, reference[0].Value.Scalar.Type) + assert.Equal(t, int64(2000), reference[0].Value.Scalar.Int) + assert.Equal(t, result["reference"], result["native"]) +} + +func loadConvertingEngines() []struct { + name string + load func(context.Context, []byte, automerge.ActorID) (*automerge.Document, error) +} { + return []struct { + name string + load func(context.Context, []byte, automerge.ActorID) (*automerge.Document, error) + }{ + {"native", automerge.LoadConvertingStrings}, + {"reference", automerge.LoadReferenceConvertingStrings}, + } +} + +// TestRustConvert_StringsInMapsAreConvertedToText reproduces +// test_strings_in_maps_are_converted_to_text. +func TestRustConvert_StringsInMapsAreConvertedToText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + source, err := automerge.New(ctx, actor(0xaa)) + require.NoError(t, err) + require.NoError(t, source.Root().PutScalar( + ctx, + "somestring", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "hello"}, + )) + _, err = source.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + saved, err := source.Save(ctx) + require.NoError(t, err) + require.NoError(t, source.Close(ctx)) + + for _, engine := range loadConvertingEngines() { + document, err := engine.load(ctx, saved, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, document) + + object, err := document.Root().Object(ctx, "somestring") + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeText, object.Type) + + text, err := object.Text() + require.NoError(t, err) + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "hello", value) + } +} + +// TestRustConvert_StringsInListsAreConvertedToText reproduces +// test_strings_in_lists_are_converted_to_text. +func TestRustConvert_StringsInListsAreConvertedToText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + source, err := automerge.New(ctx, actor(0xaa)) + require.NoError(t, err) + list, err := source.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeString, String: "hello"})) + _, err = source.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + saved, err := source.Save(ctx) + require.NoError(t, err) + require.NoError(t, source.Close(ctx)) + + for _, engine := range loadConvertingEngines() { + document, err := engine.load(ctx, saved, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, document) + + listObject, err := document.Root().Object(ctx, "list") + require.NoError(t, err) + element, err := listObject.ObjectAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeText, element.Type) + + text, err := element.Text() + require.NoError(t, err) + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "hello", value) + } +} + +// TestRustConvert_DoesNotAddSizeWhenStringsAreNotConverted reproduces +// test_does_not_add_size_when_strings_are_not_converted. +func TestRustConvert_DoesNotAddSizeWhenStringsAreNotConverted(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + empty, err := automerge.New(ctx, actor(0xaa)) + require.NoError(t, err) + + saved, err := empty.Save(ctx) + require.NoError(t, err) + require.NoError(t, empty.Close(ctx)) + + for _, engine := range loadConvertingEngines() { + document, err := engine.load(ctx, saved, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, document) + + resaved, err := document.Save(ctx) + require.NoError(t, err) + assert.Equal(t, len(saved), len(resaved)) + } +} diff --git a/pkg/automerge/core_model_test.go b/pkg/automerge/core_model_test.go new file mode 100644 index 0000000000..4c52ff2740 --- /dev/null +++ b/pkg/automerge/core_model_test.go @@ -0,0 +1,1717 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "fmt" + "math" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestDocument_StringParity(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(125)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(125)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + require.NoError(t, document.PutString(ctx, "title", "first")) + require.NoError(t, document.PutString(ctx, "title", "second")) + _, err = document.Commit(ctx, "set title", commitTime) + require.NoError(t, err) + value, err := document.String(ctx, "title") + require.NoError(t, err) + assert.Equal(t, "second", value) + + values, err := document.Scalars(ctx, "title") + require.NoError(t, err) + require.Len(t, values, 1) + } + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceData, err := referenceDocument.Save(ctx) + require.NoError(t, err) + nativeFromReference, err := automerge.Load( + ctx, + referenceData, + actor(126), + ) + require.NoError(t, err) + closeDocument(t, nativeFromReference) + + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(127), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + + for _, document := range []*automerge.Document{ + nativeFromReference, + referenceFromNative, + } { + value, err := document.String(ctx, "title") + require.NoError(t, err) + assert.Equal(t, "second", value) + } +} + +func TestDocument_ConcurrentStringWinnerMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := automerge.New(ctx, actor(128)) + require.NoError(t, err) + closeDocument(t, base) + require.NoError(t, base.PutString(ctx, "title", "base")) + _, err = base.Commit(ctx, "base", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + left, err := automerge.Load(ctx, baseData, actor(129)) + require.NoError(t, err) + closeDocument(t, left) + require.NoError(t, left.PutString(ctx, "title", "left")) + _, err = left.Commit(ctx, "left", commitTime.Add(time.Second)) + require.NoError(t, err) + + right, err := automerge.Load(ctx, baseData, actor(130)) + require.NoError(t, err) + closeDocument(t, right) + require.NoError(t, right.PutString(ctx, "title", "right")) + _, err = right.Commit(ctx, "right", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + nativeValue, err := left.String(ctx, "title") + require.NoError(t, err) + nativeConflicts, err := left.Scalars(ctx, "title") + require.NoError(t, err) + require.Len(t, nativeConflicts, 2) + + merged, err := left.Save(ctx) + require.NoError(t, err) + + reference, err := automerge.LoadReference(ctx, merged, actor(131)) + require.NoError(t, err) + closeDocument(t, reference) + referenceValue, err := reference.String(ctx, "title") + require.NoError(t, err) + assert.Equal(t, referenceValue, nativeValue) + + referenceConflicts, err := reference.Scalars(ctx, "title") + require.NoError(t, err) + assert.ElementsMatch(t, referenceConflicts, nativeConflicts) + + require.NoError(t, left.PutString(ctx, "title", "resolved")) + _, err = left.Commit(ctx, "resolve conflict", commitTime.Add(3*time.Second)) + require.NoError(t, err) + resolved, err := left.Scalars(ctx, "title") + require.NoError(t, err) + require.Len(t, resolved, 1) + assert.Equal(t, "resolved", resolved[0].String) +} + +func TestDocument_AllScalarTypesMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + values := []automerge.Scalar{ + {Type: automerge.ScalarTypeNull}, + {Type: automerge.ScalarTypeBoolean, Bool: false}, + {Type: automerge.ScalarTypeBoolean, Bool: true}, + {Type: automerge.ScalarTypeUint, Uint: math.MaxUint64}, + {Type: automerge.ScalarTypeInt, Int: math.MinInt64}, + {Type: automerge.ScalarTypeFloat64, Float: math.Inf(1)}, + {Type: automerge.ScalarTypeFloat64, Float: math.NaN()}, + {Type: automerge.ScalarTypeString, String: "Hello πŸ˜€"}, + {Type: automerge.ScalarTypeBytes, Bytes: []byte{0, 1, 254, 255}}, + {Type: automerge.ScalarTypeCounter, Int: -42}, + {Type: automerge.ScalarTypeTimestamp, Int: 1_786_147_200_000}, + } + + nativeDocument, err := automerge.New(ctx, actor(132)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(132)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for index, value := range values { + key := fmt.Sprintf("value-%d", index) + require.NoError(t, nativeDocument.PutScalar(ctx, key, value)) + require.NoError(t, referenceDocument.PutScalar(ctx, key, value)) + } + + _, err = nativeDocument.Commit(ctx, "put scalars", commitTime) + require.NoError(t, err) + _, err = referenceDocument.Commit(ctx, "put scalars", commitTime) + require.NoError(t, err) + + for index, expected := range values { + key := fmt.Sprintf("value-%d", index) + nativeValue, err := nativeDocument.Scalar(ctx, key) + require.NoError(t, err) + referenceValue, err := referenceDocument.Scalar(ctx, key) + require.NoError(t, err) + assertScalarEqual(t, expected, nativeValue) + assertScalarEqual(t, expected, referenceValue) + assertScalarEqual(t, referenceValue, nativeValue) + } + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(133), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + + for index, expected := range values { + value, err := referenceFromNative.Scalar( + ctx, + fmt.Sprintf("value-%d", index), + ) + require.NoError(t, err) + assertScalarEqual(t, expected, value) + } +} + +func TestDocument_NestedMapsAndListsMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(134)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(134)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + root := document.Root() + config, err := root.CreateObject(ctx, "config", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, config.PutScalar( + ctx, + "enabled", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + )) + + items, err := root.CreateObject(ctx, "items", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, items.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "first"}, + )) + nested, err := items.InsertObject(ctx, 1, automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, nested.PutScalar( + ctx, + "count", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + require.NoError(t, items.InsertScalar( + ctx, + 2, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "last"}, + )) + require.NoError(t, items.DeleteIndex(ctx, 0)) + require.NoError(t, items.PutScalarAt( + ctx, + 1, + automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: "replaced", + }, + )) + require.NoError(t, config.DeleteKey(ctx, "enabled")) + + _, err = document.Commit(ctx, "nested values", commitTime) + require.NoError(t, err) + + length, err := items.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), length) + + readNested, err := items.ObjectAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, readNested.Type) + count, err := readNested.Scalar(ctx, "count") + require.NoError(t, err) + assert.Equal(t, int64(2), count.Int) + + last, err := items.ScalarAt(ctx, 1) + require.NoError(t, err) + assert.Equal(t, "replaced", last.String) + + _, err = config.Scalar(ctx, "enabled") + require.Error(t, err) + } + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(135), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + items, err := referenceFromNative.Root().Object(ctx, "items") + require.NoError(t, err) + length, err := items.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), length) + + nested, err := items.ObjectAt(ctx, 0) + require.NoError(t, err) + count, err := nested.Scalar(ctx, "count") + require.NoError(t, err) + assert.Equal(t, int64(2), count.Int) +} + +func TestDocument_LoadedObjectRemainsEditable(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(136)) + require.NoError(t, err) + closeDocument(t, document) + _, err = document.Root().CreateObject(ctx, "items", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = document.Commit(ctx, "create list", commitTime) + require.NoError(t, err) + data, err := document.Save(ctx) + require.NoError(t, err) + + loaded, err := automerge.Load(ctx, data, actor(137)) + require.NoError(t, err) + closeDocument(t, loaded) + items, err := loaded.Root().Object(ctx, "items") + require.NoError(t, err) + require.NoError(t, items.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeUint, Uint: 1}, + )) + _, err = loaded.Commit(ctx, "insert item", commitTime.Add(time.Second)) + require.NoError(t, err) + data, err = loaded.Save(ctx) + require.NoError(t, err) + + reference, err := automerge.LoadReference(ctx, data, actor(138)) + require.NoError(t, err) + closeDocument(t, reference) + referenceItems, err := reference.Root().Object(ctx, "items") + require.NoError(t, err) + value, err := referenceItems.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, uint64(1), value.Uint) +} + +func TestDocument_CountersMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(139)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(139)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + root := document.Root() + require.NoError(t, root.PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 5}, + )) + require.NoError(t, root.PutScalar( + ctx, + "integer", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 5}, + )) + list, err := root.CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 10}, + )) + require.NoError(t, list.InsertScalar( + ctx, + 1, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 10}, + )) + _, err = document.Commit(ctx, "create counters", commitTime) + require.NoError(t, err) + + require.Error(t, root.Increment(ctx, "integer", 1)) + require.Error(t, list.IncrementAt(ctx, 1, 1)) + require.NoError(t, root.Increment(ctx, "counter", 3)) + require.NoError(t, root.Increment(ctx, "counter", -2)) + require.NoError(t, list.IncrementAt(ctx, 0, -4)) + _, err = document.Commit( + ctx, + "increment counters", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + + counter, err := root.Scalar(ctx, "counter") + require.NoError(t, err) + assert.Equal(t, int64(6), counter.Int) + + listCounter, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(6), listCounter.Int) + } + + base, err := automerge.New(ctx, actor(140)) + require.NoError(t, err) + closeDocument(t, base) + require.NoError(t, base.Root().PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 5}, + )) + _, err = base.Commit(ctx, "base counter", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + left, err := automerge.Load(ctx, baseData, actor(141)) + require.NoError(t, err) + closeDocument(t, left) + require.NoError(t, left.Root().Increment(ctx, "counter", 2)) + _, err = left.Commit(ctx, "left increment", commitTime.Add(time.Second)) + require.NoError(t, err) + right, err := automerge.Load(ctx, baseData, actor(142)) + require.NoError(t, err) + closeDocument(t, right) + require.NoError(t, right.Root().Increment(ctx, "counter", 3)) + _, err = right.Commit(ctx, "right increment", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + value, err := left.Root().Scalar(ctx, "counter") + require.NoError(t, err) + assert.Equal(t, int64(10), value.Int) + + data, err := left.Save(ctx) + require.NoError(t, err) + reference, err := automerge.LoadReference(ctx, data, actor(143)) + require.NoError(t, err) + closeDocument(t, reference) + referenceValue, err := reference.Root().Scalar(ctx, "counter") + require.NoError(t, err) + assert.Equal(t, value.Int, referenceValue.Int) +} + +func TestDocument_CounterDeletionMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + document, err := factory(ctx, actor(160)) + require.NoError(t, err) + closeDocument(t, document) + root := document.Root() + require.NoError(t, root.PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 1}, + )) + list, err := root.CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 1}, + )) + _, err = document.Commit(ctx, "counters", commitTime) + require.NoError(t, err) + + require.NoError(t, root.DeleteKey(ctx, "counter")) + require.NoError(t, list.DeleteIndex(ctx, 0)) + }) + } +} + +func TestDocument_RandomListParity(t *testing.T) { + t.Parallel() + + const ( + histories = 10 + steps = 100 + ) + + ctx := context.Background() + + for history := range histories { + random := rand.New(rand.NewSource(int64(20_000 + history))) + actorID := actor(byte(150 + history)) + nativeDocument, err := automerge.New(ctx, actorID) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actorID) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeList, err := nativeDocument.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + referenceList, err := referenceDocument.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + nativeHash, err := nativeDocument.Commit(ctx, "create list", commitTime) + require.NoError(t, err) + referenceHash, err := referenceDocument.Commit( + ctx, + "create list", + commitTime, + ) + require.NoError(t, err) + assert.Equal(t, referenceHash, nativeHash) + + var model []int64 + + for step := range steps { + switch { + case len(model) == 0 || random.Intn(3) == 0: + index := random.Intn(len(model) + 1) + value := random.Int63() + + model = append(model, 0) + copy(model[index+1:], model[index:]) + model[index] = value + scalar := automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + } + require.NoError( + t, + nativeList.InsertScalar(ctx, uint64(index), scalar), + ) + require.NoError( + t, + referenceList.InsertScalar(ctx, uint64(index), scalar), + ) + case random.Intn(2) == 0: + index := random.Intn(len(model)) + model = append(model[:index], model[index+1:]...) + require.NoError( + t, + nativeList.DeleteIndex(ctx, uint64(index)), + ) + require.NoError( + t, + referenceList.DeleteIndex(ctx, uint64(index)), + ) + default: + index := random.Intn(len(model)) + value := random.Int63() + model[index] = value + scalar := automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + } + require.NoError( + t, + nativeList.PutScalarAt(ctx, uint64(index), scalar), + ) + require.NoError( + t, + referenceList.PutScalarAt(ctx, uint64(index), scalar), + ) + } + + message := fmt.Sprintf("history %d step %d", history, step) + timestamp := commitTime.Add(time.Duration(step+1) * time.Second) + nativeHash, err = nativeDocument.Commit(ctx, message, timestamp) + require.NoError(t, err) + referenceHash, err = referenceDocument.Commit( + ctx, + message, + timestamp, + ) + require.NoError(t, err) + assert.Equal( + t, + referenceHash, + nativeHash, + "history %d step %d", + history, + step, + ) + + nativeLength, err := nativeList.Len(ctx) + require.NoError(t, err) + referenceLength, err := referenceList.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(len(model)), nativeLength) + assert.Equal(t, referenceLength, nativeLength) + + for index, expected := range model { + nativeValue, err := nativeList.ScalarAt(ctx, uint64(index)) + require.NoError(t, err) + referenceValue, err := referenceList.ScalarAt( + ctx, + uint64(index), + ) + require.NoError(t, err) + assert.Equal(t, expected, nativeValue.Int) + assertScalarEqual(t, referenceValue, nativeValue) + } + } + } +} + +func TestDocument_RandomMapParity(t *testing.T) { + t.Parallel() + + const ( + histories = 10 + steps = 100 + ) + + ctx := context.Background() + + for history := range histories { + random := rand.New(rand.NewSource(int64(30_000 + history))) + actorID := actor(byte(170 + history)) + nativeDocument, err := automerge.New(ctx, actorID) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actorID) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeMap, err := nativeDocument.Root().CreateObject( + ctx, + "map", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + referenceMap, err := referenceDocument.Root().CreateObject( + ctx, + "map", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + nativeHash, err := nativeDocument.Commit(ctx, "create map", commitTime) + require.NoError(t, err) + referenceHash, err := referenceDocument.Commit( + ctx, + "create map", + commitTime, + ) + require.NoError(t, err) + assert.Equal(t, referenceHash, nativeHash) + + model := make(map[string]int64) + keys := []string{"", "a", "b", "c", "d", "e"} + + for step := range steps { + key := keys[random.Intn(len(keys))] + + operation := "put" + if _, exists := model[key]; exists && random.Intn(3) == 0 { + operation = "delete" + + delete(model, key) + require.NoError(t, nativeMap.DeleteKey(ctx, key)) + require.NoError(t, referenceMap.DeleteKey(ctx, key)) + } else { + value := random.Int63() + model[key] = value + scalar := automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + } + require.NoError(t, nativeMap.PutScalar(ctx, key, scalar)) + require.NoError(t, referenceMap.PutScalar(ctx, key, scalar)) + } + + message := fmt.Sprintf("history %d step %d", history, step) + timestamp := commitTime.Add(time.Duration(step+1) * time.Second) + nativeHash, err = nativeDocument.Commit(ctx, message, timestamp) + require.NoError(t, err) + referenceHash, err = referenceDocument.Commit( + ctx, + message, + timestamp, + ) + require.NoError(t, err) + assert.Equal( + t, + referenceHash, + nativeHash, + "history %d step %d %s key %q", + history, + step, + operation, + key, + ) + + for _, candidate := range keys { + expected, exists := model[candidate] + nativeValue, nativeErr := nativeMap.Scalar(ctx, candidate) + referenceValue, referenceErr := referenceMap.Scalar( + ctx, + candidate, + ) + + if !exists { + require.Error(t, nativeErr) + require.Error(t, referenceErr) + + continue + } + + require.NoError(t, nativeErr) + require.NoError(t, referenceErr) + assert.Equal(t, expected, nativeValue.Int) + assertScalarEqual(t, referenceValue, nativeValue) + } + } + } +} + +func TestDocument_RollbackMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(180)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(180)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + require.NoError(t, document.PutString(ctx, "value", "committed")) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + headsBefore, err := document.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, document.PutString(ctx, "value", "rolled back")) + list, err := document.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + cancelled, err := document.Rollback(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(3), cancelled) + + value, err := document.String(ctx, "value") + require.NoError(t, err) + assert.Equal(t, "committed", value) + + _, err = document.Root().Object(ctx, "list") + require.Error(t, err) + headsAfter, err := document.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, headsBefore, headsAfter) + + cancelled, err = document.Rollback(ctx) + require.NoError(t, err) + assert.Zero(t, cancelled) + } +} + +func TestDocument_ForkMatchesReference(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(161)) + require.NoError(t, err) + closeDocument(t, document) + require.NoError(t, document.PutString(ctx, "base", "value")) + _, err = document.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + fork, err := document.Fork(ctx, actor(162)) + require.NoError(t, err) + closeDocument(t, fork) + require.NoError(t, fork.PutString(ctx, "fork", "value")) + _, err = fork.Commit( + ctx, + "fork", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + + _, err = document.String(ctx, "fork") + require.Error(t, err) + value, err := fork.String(ctx, "base") + require.NoError(t, err) + assert.Equal(t, "value", value) + + _, err = document.Merge(ctx, fork) + require.NoError(t, err) + value, err = document.String(ctx, "fork") + require.NoError(t, err) + assert.Equal(t, "value", value) + }) + } +} + +func TestDocument_WrongObjectOperationsMatchReference(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(171)) + require.NoError(t, err) + closeDocument(t, document) + root := document.Root() + mapObject, err := root.CreateObject( + ctx, + "map", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + listObject, err := root.CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + textObject, err := root.CreateObject( + ctx, + "text", + automerge.ObjectTypeText, + ) + require.NoError(t, err) + + scalar := automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: 1, + } + require.Error(t, listObject.PutScalar(ctx, "key", scalar)) + require.Error(t, textObject.PutScalar(ctx, "key", scalar)) + require.Error(t, mapObject.InsertScalar(ctx, 0, scalar)) + require.Error(t, mapObject.DeleteIndex(ctx, 0)) + length, err := mapObject.Len(ctx) + require.NoError(t, err) + assert.Zero(t, length) + + _, err = listObject.Text() + require.Error(t, err) + }) + } +} + +func TestDocument_DeletedObjectsSaveLoad(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(172)) + require.NoError(t, err) + closeDocument(t, document) + + root := document.Root() + for key, objectType := range map[string]automerge.ObjectType{ + "list": automerge.ObjectTypeList, + "text": automerge.ObjectTypeText, + "map": automerge.ObjectTypeMap, + "table": automerge.ObjectTypeTable, + } { + _, err := root.CreateObject(ctx, key, objectType) + require.NoError(t, err) + require.NoError(t, root.DeleteKey(ctx, key)) + } + + _, err = document.Commit(ctx, "deleted objects", commitTime) + require.NoError(t, err) + data, err := document.Save(ctx) + require.NoError(t, err) + + nativeLoaded, err := automerge.Load(ctx, data, actor(173)) + require.NoError(t, err) + closeDocument(t, nativeLoaded) + + referenceLoaded, err := automerge.LoadReference(ctx, data, actor(174)) + require.NoError(t, err) + closeDocument(t, referenceLoaded) + + for _, loaded := range []*automerge.Document{ + nativeLoaded, + referenceLoaded, + } { + length, err := loaded.Root().Len(ctx) + require.NoError(t, err) + assert.Zero(t, length) + } +} + +func TestDocument_ManyMapDeletes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(175)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(175)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + object, err := document.Root().CreateObject( + ctx, + "object", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + + for index := range 100 { + key := fmt.Sprintf("%d", index) + require.NoError(t, object.PutScalar( + ctx, + key, + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: int64(index), + }, + )) + require.NoError(t, object.DeleteKey(ctx, key)) + } + + _, err = document.Commit(ctx, "many deletes", commitTime) + require.NoError(t, err) + length, err := object.Len(ctx) + require.NoError(t, err) + assert.Zero(t, length) + } +} + +func TestDocument_MapKeysMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + document, err := factory(ctx, actor(179)) + require.NoError(t, err) + closeDocument(t, document) + keys, err := document.Root().Keys(ctx) + require.NoError(t, err) + assert.Empty(t, keys) + + object, err := document.Root().CreateObject( + ctx, + "map", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + + for _, key := range []string{"z", "", "a@b", "a"} { + require.NoError(t, object.PutScalar( + ctx, + key, + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: 1, + }, + )) + } + + require.NoError(t, object.DeleteKey(ctx, "z")) + keys, err = object.Keys(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"", "a", "a@b"}, keys) + + _, err = document.Commit(ctx, "keys", commitTime) + require.NoError(t, err) + loaded, err := document.Fork(ctx, actor(180)) + require.NoError(t, err) + closeDocument(t, loaded) + loadedObject, err := loaded.Root().Object(ctx, "map") + require.NoError(t, err) + keys, err = loadedObject.Keys(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"", "a", "a@b"}, keys) + }) + } +} + +func TestDocument_NoOpMergeAndEqualPutMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + document, err := factory(ctx, actor(176)) + require.NoError(t, err) + closeDocument(t, document) + require.NoError(t, document.PutString(ctx, "value", "same")) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + + fork, err := document.Fork(ctx, actor(177)) + require.NoError(t, err) + closeDocument(t, fork) + _, err = fork.EmptyCommit( + ctx, + "noop", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + require.NoError(t, fork.PutString(ctx, "value", "changed")) + _, err = fork.Commit( + ctx, + "real", + commitTime.Add(2*time.Second), + ) + require.NoError(t, err) + + require.NoError(t, document.PutString(ctx, "value", "same")) + _, err = document.Commit( + ctx, + "equal", + commitTime.Add(time.Second), + ) + require.Error(t, err) + _, err = document.Merge(ctx, fork) + require.NoError(t, err) + value, err := document.String(ctx, "value") + require.NoError(t, err) + assert.Equal(t, "changed", value) + + data, err := document.Save(ctx) + require.NoError(t, err) + loaded, err := automerge.Load(ctx, data, actor(178)) + require.NoError(t, err) + closeDocument(t, loaded) + value, err = loaded.String(ctx, "value") + require.NoError(t, err) + assert.Equal(t, "changed", value) + }) + } +} + +func TestDocument_RandomConcurrentMapParity(t *testing.T) { + t.Parallel() + + const ( + histories = 20 + steps = 30 + ) + + ctx := context.Background() + keys := []string{"a", "b", "c", "d", "e", "f", "g", "h"} + + for history := range histories { + base, err := automerge.New(ctx, actor(byte(190+history))) + require.NoError(t, err) + closeDocument(t, base) + baseMap, err := base.Root().CreateObject( + ctx, + "map", + automerge.ObjectTypeMap, + ) + require.NoError(t, err) + + for index, key := range keys { + require.NoError(t, baseMap.PutScalar( + ctx, + key, + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: int64(index), + }, + )) + } + + _, err = base.Commit(ctx, "base map", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + left, err := automerge.Load(ctx, baseData, actor(byte(210+history))) + require.NoError(t, err) + closeDocument(t, left) + leftMap, err := left.Root().Object(ctx, "map") + require.NoError(t, err) + right, err := automerge.Load(ctx, baseData, actor(byte(230+history))) + require.NoError(t, err) + closeDocument(t, right) + rightMap, err := right.Root().Object(ctx, "map") + require.NoError(t, err) + referenceLeft, err := automerge.LoadReference( + ctx, + baseData, + actor(byte(210+history)), + ) + require.NoError(t, err) + closeDocument(t, referenceLeft) + referenceLeftMap, err := referenceLeft.Root().Object(ctx, "map") + require.NoError(t, err) + referenceRight, err := automerge.LoadReference( + ctx, + baseData, + actor(byte(230+history)), + ) + require.NoError(t, err) + closeDocument(t, referenceRight) + referenceRightMap, err := referenceRight.Root().Object(ctx, "map") + require.NoError(t, err) + + leftSeed := int64(40_000 + history) + mutateRandomMap( + t, + ctx, + rand.New(rand.NewSource(leftSeed)), + left, + leftMap, + keys, + steps, + "left", + ) + mutateRandomMap( + t, + ctx, + rand.New(rand.NewSource(leftSeed)), + referenceLeft, + referenceLeftMap, + keys, + steps, + "left", + ) + + rightSeed := int64(50_000 + history) + mutateRandomMap( + t, + ctx, + rand.New(rand.NewSource(rightSeed)), + right, + rightMap, + keys, + steps, + "right", + ) + mutateRandomMap( + t, + ctx, + rand.New(rand.NewSource(rightSeed)), + referenceRight, + referenceRightMap, + keys, + steps, + "right", + ) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + _, err = right.Merge(ctx, left) + require.NoError(t, err) + _, err = referenceLeft.Merge(ctx, referenceRight) + require.NoError(t, err) + _, err = referenceRight.Merge(ctx, referenceLeft) + require.NoError(t, err) + leftMap, err = left.Root().Object(ctx, "map") + require.NoError(t, err) + rightMap, err = right.Root().Object(ctx, "map") + require.NoError(t, err) + assertMapParity(t, ctx, keys, leftMap, rightMap) + referenceLeftMap, err = referenceLeft.Root().Object(ctx, "map") + require.NoError(t, err) + referenceRightMap, err = referenceRight.Root().Object(ctx, "map") + require.NoError(t, err) + assertMapParity(t, ctx, keys, referenceLeftMap, referenceRightMap) + assertMapParity(t, ctx, keys, leftMap, referenceLeftMap) + } +} + +func TestDocument_RandomConcurrentListParity(t *testing.T) { + t.Parallel() + + const ( + histories = 20 + steps = 30 + ) + + ctx := context.Background() + for history := range histories { + base, err := automerge.New(ctx, actor(byte(10+history))) + require.NoError(t, err) + closeDocument(t, base) + baseList, err := base.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + + for index := range 8 { + require.NoError(t, baseList.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: int64(index), + }, + )) + } + + _, err = base.Commit(ctx, "base list", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + left, err := automerge.Load(ctx, baseData, actor(byte(30+history))) + require.NoError(t, err) + closeDocument(t, left) + leftList, err := left.Root().Object(ctx, "list") + require.NoError(t, err) + right, err := automerge.Load(ctx, baseData, actor(byte(50+history))) + require.NoError(t, err) + closeDocument(t, right) + rightList, err := right.Root().Object(ctx, "list") + require.NoError(t, err) + referenceLeft, err := automerge.LoadReference( + ctx, + baseData, + actor(byte(30+history)), + ) + require.NoError(t, err) + closeDocument(t, referenceLeft) + referenceLeftList, err := referenceLeft.Root().Object(ctx, "list") + require.NoError(t, err) + referenceRight, err := automerge.LoadReference( + ctx, + baseData, + actor(byte(50+history)), + ) + require.NoError(t, err) + closeDocument(t, referenceRight) + referenceRightList, err := referenceRight.Root().Object(ctx, "list") + require.NoError(t, err) + + leftSeed := int64(60_000 + history) + mutateRandomList( + t, + ctx, + rand.New(rand.NewSource(leftSeed)), + left, + leftList, + steps, + "left", + ) + mutateRandomList( + t, + ctx, + rand.New(rand.NewSource(leftSeed)), + referenceLeft, + referenceLeftList, + steps, + "left", + ) + + rightSeed := int64(70_000 + history) + mutateRandomList( + t, + ctx, + rand.New(rand.NewSource(rightSeed)), + right, + rightList, + steps, + "right", + ) + mutateRandomList( + t, + ctx, + rand.New(rand.NewSource(rightSeed)), + referenceRight, + referenceRightList, + steps, + "right", + ) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + _, err = right.Merge(ctx, left) + require.NoError(t, err) + _, err = referenceLeft.Merge(ctx, referenceRight) + require.NoError(t, err) + _, err = referenceRight.Merge(ctx, referenceLeft) + require.NoError(t, err) + leftList, err = left.Root().Object(ctx, "list") + require.NoError(t, err) + rightList, err = right.Root().Object(ctx, "list") + require.NoError(t, err) + assertListParity(t, ctx, leftList, rightList) + referenceLeftList, err = referenceLeft.Root().Object(ctx, "list") + require.NoError(t, err) + referenceRightList, err = referenceRight.Root().Object(ctx, "list") + require.NoError(t, err) + assertListParity(t, ctx, referenceLeftList, referenceRightList) + assertListParity(t, ctx, leftList, referenceLeftList) + } +} + +func TestDocument_ConcurrentListOrderingMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + results := make(map[string][]string) + + for name, factory := range factories { + base, err := factory(ctx, actor(80)) + require.NoError(t, err) + closeDocument(t, base) + list, err := base.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "A"}, + )) + _, err = base.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + left, err := base.Fork(ctx, actor(81)) + require.NoError(t, err) + closeDocument(t, left) + leftList, err := left.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, leftList.InsertValues( + ctx, + 1, + []automerge.Value{ + hydratedString("L1"), + hydratedString("L2"), + }, + )) + _, err = left.Commit(ctx, "left", commitTime.Add(time.Second)) + require.NoError(t, err) + + right, err := base.Fork(ctx, actor(82)) + require.NoError(t, err) + closeDocument(t, right) + rightList, err := right.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, rightList.InsertValues( + ctx, + 1, + []automerge.Value{ + hydratedString("R1"), + hydratedString("R2"), + }, + )) + _, err = right.Commit(ctx, "right", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + leftList, err = left.Root().Object(ctx, "list") + require.NoError(t, err) + values := listStrings(t, ctx, leftList) + assert.Equal(t, []string{"A", "R1", "R2", "L1", "L2"}, values) + results[name] = values + } + + assert.Equal(t, results["reference"], results["native"]) +} + +func TestDocument_InsertAfterConcurrentDeleteMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + results := make(map[string][]string) + + for name, factory := range factories { + base, err := factory(ctx, actor(83)) + require.NoError(t, err) + closeDocument(t, base) + list, err := base.Root().CreateObject( + ctx, + "list", + automerge.ObjectTypeList, + ) + require.NoError(t, err) + require.NoError(t, list.InsertValues( + ctx, + 0, + []automerge.Value{ + hydratedString("A"), + hydratedString("B"), + }, + )) + _, err = base.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + left, err := base.Fork(ctx, actor(84)) + require.NoError(t, err) + closeDocument(t, left) + leftList, err := left.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, leftList.DeleteIndex(ctx, 0)) + _, err = left.Commit(ctx, "delete", commitTime.Add(time.Second)) + require.NoError(t, err) + + right, err := base.Fork(ctx, actor(85)) + require.NoError(t, err) + closeDocument(t, right) + rightList, err := right.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, rightList.InsertScalar( + ctx, + 1, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "X"}, + )) + _, err = right.Commit(ctx, "insert", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + leftList, err = left.Root().Object(ctx, "list") + require.NoError(t, err) + results[name] = listStrings(t, ctx, leftList) + } + + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, []string{"X", "B"}, results["native"]) +} + +func hydratedString(value string) automerge.Value { + return automerge.Value{ + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: value, + }, + } +} + +func listStrings( + t *testing.T, + ctx context.Context, + list *automerge.Object, +) []string { + t.Helper() + + length, err := list.Len(ctx) + require.NoError(t, err) + + values := make([]string, length) + for index := range length { + value, err := list.ScalarAt(ctx, index) + require.NoError(t, err) + + values[index] = value.String + } + + return values +} + +func mutateRandomMap( + t *testing.T, + ctx context.Context, + random *rand.Rand, + document *automerge.Document, + object *automerge.Object, + keys []string, + steps int, + side string, +) { + t.Helper() + + present := make(map[string]bool, len(keys)) + for _, key := range keys { + present[key] = true + } + + for step := range steps { + key := keys[random.Intn(len(keys))] + if present[key] && random.Intn(4) == 0 { + require.NoError(t, object.DeleteKey(ctx, key)) + present[key] = false + } else { + require.NoError(t, object.PutScalar( + ctx, + key, + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: random.Int63(), + }, + )) + present[key] = true + } + + if step%5 == 4 { + _, err := document.Commit( + ctx, + fmt.Sprintf("%s map step %d", side, step), + commitTime.Add(time.Duration(step+1)*time.Second), + ) + require.NoError(t, err) + } + } +} + +func mutateRandomList( + t *testing.T, + ctx context.Context, + random *rand.Rand, + document *automerge.Document, + object *automerge.Object, + steps int, + side string, +) { + t.Helper() + + length := 8 + + for step := range steps { + switch { + case length == 0 || random.Intn(3) == 0: + index := random.Intn(length + 1) + require.NoError(t, object.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: random.Int63(), + }, + )) + + length++ + case random.Intn(2) == 0: + index := random.Intn(length) + require.NoError(t, object.DeleteIndex(ctx, uint64(index))) + + length-- + default: + index := random.Intn(length) + require.NoError(t, object.PutScalarAt( + ctx, + uint64(index), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: random.Int63(), + }, + )) + } + + if step%5 == 4 { + _, err := document.Commit( + ctx, + fmt.Sprintf("%s list step %d", side, step), + commitTime.Add(time.Duration(step+1)*time.Second), + ) + require.NoError(t, err) + } + } +} + +func assertMapParity( + t *testing.T, + ctx context.Context, + keys []string, + left *automerge.Object, + right *automerge.Object, +) { + t.Helper() + + for _, key := range keys { + leftValues, leftErr := left.Scalars(ctx, key) + rightValues, rightErr := right.Scalars(ctx, key) + assert.Equal(t, leftErr != nil, rightErr != nil, "key %q", key) + + if leftErr == nil && rightErr == nil { + assert.ElementsMatch(t, leftValues, rightValues, "key %q", key) + } + } +} + +func assertListParity( + t *testing.T, + ctx context.Context, + left *automerge.Object, + right *automerge.Object, +) { + t.Helper() + + leftLength, err := left.Len(ctx) + require.NoError(t, err) + rightLength, err := right.Len(ctx) + require.NoError(t, err) + require.Equal(t, leftLength, rightLength) + + for index := range leftLength { + leftValue, err := left.ScalarAt(ctx, index) + require.NoError(t, err) + rightValue, err := right.ScalarAt(ctx, index) + require.NoError(t, err) + assertScalarEqual(t, leftValue, rightValue) + } +} + +func assertScalarEqual( + t *testing.T, + expected automerge.Scalar, + actual automerge.Scalar, +) { + t.Helper() + + assert.Equal(t, expected.Type, actual.Type) + assert.Equal(t, expected.Bool, actual.Bool) + assert.Equal(t, expected.Uint, actual.Uint) + assert.Equal(t, expected.Int, actual.Int) + assert.Equal(t, math.Float64bits(expected.Float), math.Float64bits(actual.Float)) + assert.Equal(t, expected.String, actual.String) + assert.Equal(t, expected.Bytes, actual.Bytes) +} diff --git a/pkg/automerge/counter_patch_parity_test.go b/pkg/automerge/counter_patch_parity_test.go new file mode 100644 index 0000000000..4be3b2f078 --- /dev/null +++ b/pkg/automerge/counter_patch_parity_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file corresponds to observe_counter_change_application +// (rust/automerge/src/automerge/tests.rs). The upstream test applies a change +// that creates and then increments a counter and expects diff_incremental to +// replay per-operation patches (a put of the base value followed by two +// increment patches). The pinned reference engine (automerge 0.10.0 embedded as +// WASM) instead collapses that applied change into a single put through +// diff_incremental, and the native engine matches the reference exactly. Because +// the parity gate is native-matches-reference observable behavior, this test +// asserts that agreement rather than the upstream native-Rust expectation. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestRustAutomerge_ObserveCounterChangeApplication(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + source, err := engine.open(ctx, actor(0xc0)) + require.NoError(t, err) + closeDocument(t, source) + + require.NoError(t, source.Root().PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 1}, + )) + require.NoError(t, source.Root().Increment(ctx, "counter", 2)) + require.NoError(t, source.Root().Increment(ctx, "counter", 5)) + + _, err = source.Commit(ctx, "counter", commitTime) + require.NoError(t, err) + + change, err := source.SaveIncremental(ctx) + require.NoError(t, err) + + document, err := engine.open(ctx, actor(0xd0)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "foo", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "bar"}, + )) + + _, err = document.Commit(ctx, "foo", commitTime) + require.NoError(t, err) + + require.NoError(t, document.UpdateDiffCursor(ctx)) + + _, err = document.LoadIncremental(ctx, change) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + + // The reference collapses the create-and-increment sequence into a single + // put of the counter's materialized value; native reproduces it exactly. + require.Len(t, reference, 1) + assert.Equal(t, automerge.PatchPutMap, reference[0].Action) + assert.Equal(t, "counter", reference[0].Key) + require.NotNil(t, reference[0].Value.Scalar) + assert.Equal(t, automerge.ScalarTypeCounter, reference[0].Value.Scalar.Type) + assert.Equal(t, int64(8), reference[0].Value.Scalar.Int) + + assert.Equal(t, reference, result["native"]) +} diff --git a/pkg/automerge/current_state_parity_test.go b/pkg/automerge/current_state_parity_test.go new file mode 100644 index 0000000000..b7cd1877fe --- /dev/null +++ b/pkg/automerge/current_state_parity_test.go @@ -0,0 +1,369 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust current-state tests from +// automerge 0.10 (rust/automerge/src/automerge/current_state.rs). Each builds +// the same document on the native and Rust/WASM reference engines and asserts +// their materialization patch streams agree. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func basicStateDocument( + t *testing.T, + ctx context.Context, + factory func(context.Context, automerge.ActorID) (*automerge.Document, error), +) *automerge.Document { + t.Helper() + + document, err := factory(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + + mapObject, err := document.Root().CreateObject(ctx, "map", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, mapObject.PutScalar( + ctx, + "nested_key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + + _, err = document.Commit(ctx, "basic", commitTime) + require.NoError(t, err) + + return document +} + +func currentStateParity( + t *testing.T, + build func(t *testing.T, ctx context.Context, document *automerge.Document), +) map[string][]automerge.Patch { + t.Helper() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + build(t, ctx, document) + + _, err = document.CommitNow(ctx, "state") + require.NoError(t, err) + + patches, err := document.CurrentState(ctx) + require.NoError(t, err) + + result[engine.name] = patches + } + + return result +} + +// TestRustCurrentState_TextSpliced reproduces current_state test_text_spliced. +func TestRustCurrentState_TextSpliced(t *testing.T) { + t.Parallel() + + patches := currentStateParity(t, func(t *testing.T, ctx context.Context, document *automerge.Document) { + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + require.NoError(t, text.Splice(ctx, 1, 0, "bcdef")) + require.NoError(t, text.Splice(ctx, 2, 2, "g")) + }) + + assert.Equal(t, patches["reference"], patches["native"]) + require.Len(t, patches["native"], 2) + assert.Equal(t, automerge.PatchSpliceText, patches["native"][1].Action) + assert.Equal(t, "abgef", patches["native"][1].Text) +} + +// TestRustCurrentState_MultipleListInsertions reproduces +// test_multiple_list_insertions. +func TestRustCurrentState_MultipleListInsertions(t *testing.T) { + t.Parallel() + + patches := currentStateParity(t, func(t *testing.T, ctx context.Context, document *automerge.Document) { + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1})) + require.NoError(t, list.InsertScalar(ctx, 1, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2})) + }) + + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustCurrentState_ConcurrentInsertions reproduces +// test_concurrent_insertions_at_same_index. +func TestRustCurrentState_ConcurrentInsertions(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = document.Commit(ctx, "list", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, other) + + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1})) + _, err = document.Commit(ctx, "one", commitTime.Add(time.Second)) + require.NoError(t, err) + + otherList, err := other.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, otherList.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2})) + _, err = other.Commit(ctx, "two", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = document.Merge(ctx, other) + require.NoError(t, err) + + patches, err := document.CurrentState(ctx) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustCurrentState_InsertObjects reproduces test_insert_objects. +func TestRustCurrentState_InsertObjects(t *testing.T) { + t.Parallel() + + patches := currentStateParity(t, func(t *testing.T, ctx context.Context, document *automerge.Document) { + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + mapObject, err := list.InsertObject(ctx, 0, automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, mapObject.PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + }) + + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustCurrentState_InsertAndUpdate reproduces test_insert_and_update. +func TestRustCurrentState_InsertAndUpdate(t *testing.T) { + t.Parallel() + + patches := currentStateParity(t, func(t *testing.T, ctx context.Context, document *automerge.Document) { + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeString, String: "one"})) + require.NoError(t, list.InsertScalar(ctx, 1, automerge.Scalar{Type: automerge.ScalarTypeString, String: "two"})) + require.NoError(t, list.PutScalarAt(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeString, String: "three"})) + require.NoError(t, list.PutScalarAt(ctx, 1, automerge.Scalar{Type: automerge.ScalarTypeString, String: "four"})) + }) + + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustCurrentState_Counters reproduces test_counters. +func TestRustCurrentState_Counters(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, document) + require.NoError(t, document.Root().PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 1}, + )) + require.NoError(t, document.Root().Increment(ctx, "key", 2)) + require.NoError(t, document.Root().Increment(ctx, "key", 3)) + _, err = document.Commit(ctx, "counter", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, other) + // Fork copies history; give the conflicting value its own change. + require.NoError(t, other.Root().PutScalar( + ctx, + "other", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "someval"}, + )) + _, err = other.Commit(ctx, "someval", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = document.Merge(ctx, other) + require.NoError(t, err) + + patches, err := document.CurrentState(ctx) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) + + for _, patch := range result["native"] { + if patch.Key == "key" { + require.NotNil(t, patch.Value.Scalar) + assert.Equal(t, automerge.ScalarTypeCounter, patch.Value.Scalar.Type) + assert.Equal(t, int64(6), patch.Value.Scalar.Int) + } + } +} + +// TestRustCurrentState_Basic reproduces the current_state basic_test. +func TestRustCurrentState_Basic(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + nativePatches, err := basicStateDocument(t, ctx, automerge.New).CurrentState(ctx) + require.NoError(t, err) + referencePatches, err := basicStateDocument(t, ctx, automerge.NewReference).CurrentState(ctx) + require.NoError(t, err) + + assert.Equal(t, referencePatches, nativePatches) + + require.Len(t, nativePatches, 7) + + assert.Equal(t, automerge.PatchPutMap, nativePatches[0].Action) + assert.Equal(t, "key", nativePatches[0].Key) + require.NotNil(t, nativePatches[0].Value.Scalar) + assert.Equal(t, "value", nativePatches[0].Value.Scalar.String) + + assert.Equal(t, "list", nativePatches[1].Key) + assert.Equal(t, automerge.ObjectTypeList, nativePatches[1].Value.Object) + assert.Equal(t, "map", nativePatches[2].Key) + assert.Equal(t, automerge.ObjectTypeMap, nativePatches[2].Value.Object) + assert.Equal(t, "text", nativePatches[3].Key) + assert.Equal(t, automerge.ObjectTypeText, nativePatches[3].Value.Object) + + assert.Equal(t, automerge.PatchPutMap, nativePatches[4].Action) + assert.Equal(t, "nested_key", nativePatches[4].Key) + + assert.Equal(t, automerge.PatchInsert, nativePatches[5].Action) + require.Len(t, nativePatches[5].Values, 1) + assert.Equal(t, "value", nativePatches[5].Values[0].Value.Scalar.String) + + assert.Equal(t, automerge.PatchSpliceText, nativePatches[6].Action) + assert.Equal(t, "a", nativePatches[6].Text) +} + +// TestRustCurrentState_DeletedOpsOmitted reproduces +// current_state test_deleted_ops_omitted. +func TestRustCurrentState_DeletedOpsOmitted(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + build := func(factory func(context.Context, automerge.ActorID) (*automerge.Document, error)) []automerge.Patch { + document, err := factory(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + require.NoError(t, document.Root().DeleteKey(ctx, "key")) + + mapObject, err := document.Root().CreateObject(ctx, "map", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, mapObject.PutScalar( + ctx, + "nested_key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + require.NoError(t, mapObject.DeleteKey(ctx, "nested_key")) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + require.NoError(t, list.DeleteIndex(ctx, 0)) + + _, err = document.Commit(ctx, "deleted", commitTime) + require.NoError(t, err) + + patches, err := document.CurrentState(ctx) + require.NoError(t, err) + + return patches + } + + nativePatches := build(automerge.New) + referencePatches := build(automerge.NewReference) + + assert.Equal(t, referencePatches, nativePatches) + + // The deleted scalar, nested key, and list element must not appear; only the + // three surviving empty objects remain. + for _, patch := range nativePatches { + assert.NotEqual(t, "key", patch.Key) + assert.NotEqual(t, "nested_key", patch.Key) + assert.NotEqual(t, automerge.PatchInsert, patch.Action) + } +} diff --git a/pkg/automerge/cursor.go b/pkg/automerge/cursor.go new file mode 100644 index 0000000000..afda6ec9b2 --- /dev/null +++ b/pkg/automerge/cursor.go @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge + +import ( + "context" + "fmt" +) + +type CursorMove string + +const ( + CursorMoveBefore CursorMove = "before" + CursorMoveAfter CursorMove = "after" +) + +// StartCursor returns a cursor that always resolves to the sequence start. +func StartCursor() Cursor { + return Cursor{1, 1} +} + +// EndCursor returns a cursor that always resolves to the sequence end. +func EndCursor() Cursor { + return Cursor{1, 2} +} + +// CursorFor returns a stable cursor with JavaScript-compatible index clamping. +func (t *Text) CursorFor( + ctx context.Context, + index int64, + move CursorMove, +) (Cursor, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + if move != CursorMoveBefore && move != CursorMoveAfter { + return nil, fmt.Errorf("unknown Automerge cursor movement %q", move) + } + + if index < 0 { + return StartCursor(), nil + } + + value, err := t.document.backend.Text(ctx, t.handle) + if err != nil { + return nil, fmt.Errorf("cannot read Automerge text for cursor: %w", err) + } + + length := int64(utf16StringLength(value)) + if index >= length { + return EndCursor(), nil + } + + cursor, err := t.document.backend.TextCursorMoving( + ctx, + t.handle, + uint32(index), + move == CursorMoveBefore, + ) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge text cursor: %w", err) + } + + return Cursor(cursor), nil +} + +// CursorForAt returns a stable cursor for an index resolved against the text as +// it existed at a historical frontier, mirroring get_cursor with heads. +func (t *Text) CursorForAt( + ctx context.Context, + index int64, + move CursorMove, + heads []Hash, +) (Cursor, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + if move != CursorMoveBefore && move != CursorMoveAfter { + return nil, fmt.Errorf("unknown Automerge cursor movement %q", move) + } + + if index < 0 { + return StartCursor(), nil + } + + value, err := t.document.backend.TextAt(ctx, t.handle, backendHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot read historical Automerge text for cursor: %w", err) + } + + length := int64(utf16StringLength(value)) + if index >= length { + return EndCursor(), nil + } + + cursor, err := t.document.backend.TextCursorMovingAt( + ctx, + t.handle, + uint32(index), + move == CursorMoveBefore, + backendHashes(heads), + ) + if err != nil { + return nil, fmt.Errorf("cannot create historical Automerge text cursor: %w", err) + } + + return Cursor(cursor), nil +} + +// SpliceCursor resolves cursor and applies a text splice at its current position. +func (t *Text) SpliceCursor( + ctx context.Context, + cursor Cursor, + deleteCount int32, + value string, +) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + position, err := t.document.backend.TextCursorPosition( + ctx, + t.handle, + cursor, + ) + if err != nil { + return fmt.Errorf("cannot resolve Automerge text cursor: %w", err) + } + + if err := t.document.backend.SpliceText( + ctx, + t.handle, + position, + deleteCount, + value, + ); err != nil { + return fmt.Errorf("cannot splice Automerge text at cursor: %w", err) + } + + return nil +} + +func utf16StringLength(value string) uint32 { + var length uint32 + + for _, character := range value { + if character > 0xffff { + length += 2 + } else { + length++ + } + } + + return length +} diff --git a/pkg/automerge/cursors_parity_test.go b/pkg/automerge/cursors_parity_test.go new file mode 100644 index 0000000000..1f7e9a6acd --- /dev/null +++ b/pkg/automerge/cursors_parity_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the historical-cursor behavior from the +// upstream JavaScript cursor suite (javascript/test/cursors.ts), asserting the +// native Go and Rust/WASM reference engines resolve cursors created at a past +// frontier identically. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestJSCursors_GetCursorRespectsHeads reproduces "getCursor should respect +// heads": cursors created against a historical view resolve to the expected +// positions in the current document. +func TestJSCursors_GetCursorRespectsHeads(t *testing.T) { + t.Parallel() + + ctx := context.Background() + positions := make(map[string][]uint32) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "aaa@bbb") + + frontier, err := document.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 3, 1, "~~~")) + _, err = document.Commit(ctx, "replace", commitTime.Add(1)) + require.NoError(t, err) + + before, err := text.CursorForAt(ctx, 3, automerge.CursorMoveBefore, frontier) + require.NoError(t, err) + after, err := text.CursorForAt(ctx, 3, automerge.CursorMoveAfter, frontier) + require.NoError(t, err) + + start, err := text.CursorPosition(ctx, automerge.StartCursor()) + require.NoError(t, err) + beforePosition, err := text.CursorPosition(ctx, before) + require.NoError(t, err) + afterPosition, err := text.CursorPosition(ctx, after) + require.NoError(t, err) + end, err := text.CursorPosition(ctx, automerge.EndCursor()) + require.NoError(t, err) + + positions[engine.name] = []uint32{start, beforePosition, afterPosition, end} + } + + assert.Equal(t, []uint32{0, 2, 6, 9}, positions["reference"]) + assert.Equal(t, positions["reference"], positions["native"]) +} diff --git a/pkg/automerge/diff_marks_parity_test.go b/pkg/automerge/diff_marks_parity_test.go new file mode 100644 index 0000000000..f781148bc9 --- /dev/null +++ b/pkg/automerge/diff_marks_parity_test.go @@ -0,0 +1,511 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the text-and-mark scenarios from upstream +// Rust automerge 0.10 (rust/automerge/tests/diff_marks.rs). Each scenario runs +// update_spans identically on the native Go engine and the Rust/WASM reference +// engine and asserts their materialized spans agree. Block-valued scenarios are +// tracked separately and excluded here. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func markBool() automerge.Scalar { + return automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true} +} + +func markStr(value string) automerge.Scalar { + return automerge.Scalar{Type: automerge.ScalarTypeString, String: value} +} + +type diffMarksScenario struct { + name string + setup func(ctx context.Context, t *testing.T, text *automerge.Text) + spans []automerge.SpanInput + config automerge.UpdateSpansConfig + post func(ctx context.Context, t *testing.T, text *automerge.Text) +} + +func TestRustDiffMarks(t *testing.T) { + t.Parallel() + + defaultConfig := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + + spliceSetup := func(content string) func(context.Context, *testing.T, *automerge.Text) { + return func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, content)) + } + } + + markSetup := func(content string, marks ...markSpec) func(context.Context, *testing.T, *automerge.Text) { + return func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, content)) + + for _, mark := range marks { + require.NoError(t, text.Mark( + ctx, + mark.start, + mark.end, + mark.name, + mark.value, + automerge.MarkExpandBoth, + )) + } + } + } + + scenarios := []diffMarksScenario{ + { + name: "overlapping_marks_remove_one_keep_other", + setup: markSetup("hello world", markSpec{"bold", markBool(), 6, 11}, markSpec{"italic", markBool(), 6, 11}), + spans: []automerge.SpanInput{ + {Text: "hello "}, + {Text: "world", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "overlapping_marks_change_boundaries", + setup: markSetup("hello beautiful world", markSpec{"bold", markBool(), 0, 15}, markSpec{"italic", markBool(), 6, 21}), + spans: []automerge.SpanInput{ + {Text: "hello", Marks: marks("bold", markBool())}, + {Text: " beautiful "}, + {Text: "world", Marks: marks("italic", markBool())}, + }, + config: defaultConfig, + }, + { + name: "overlapping_marks_add_third_mark", + setup: markSetup("hello world", markSpec{"bold", markBool(), 0, 11}, markSpec{"italic", markBool(), 6, 11}), + spans: []automerge.SpanInput{ + {Text: "hel", Marks: marks("bold", markBool())}, + {Text: "lo wo", Marks: marks("bold", markBool(), "underline", markBool())}, + {Text: "rld", Marks: marks("bold", markBool(), "italic", markBool(), "underline", markBool())}, + }, + config: defaultConfig, + }, + { + name: "adjacent_marks_stay_separate", + setup: spliceSetup("bold text"), + spans: []automerge.SpanInput{ + {Text: "bold", Marks: marks("bold", markBool())}, + {Text: " "}, + {Text: "text", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "mark_expands", + setup: markSetup("bold text", markSpec{"bold", markBool(), 0, 4}), + spans: []automerge.SpanInput{ + {Text: "bold text", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "mark_contracts", + setup: markSetup("bold text", markSpec{"bold", markBool(), 0, 9}), + spans: []automerge.SpanInput{ + {Text: "bold", Marks: marks("bold", markBool())}, + {Text: " text"}, + }, + config: defaultConfig, + }, + { + name: "mark_shifts_position", + setup: markSetup("bold text", markSpec{"bold", markBool(), 0, 4}), + spans: []automerge.SpanInput{ + {Text: "text "}, + {Text: "bold", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "mark_splits", + setup: markSetup("bold text here", markSpec{"bold", markBool(), 0, 14}), + spans: []automerge.SpanInput{ + {Text: "bold", Marks: marks("bold", markBool())}, + {Text: " text "}, + {Text: "here", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "adjacent_marks_merge", + setup: markSetup("bold text", markSpec{"bold", markBool(), 0, 4}, markSpec{"bold", markBool(), 5, 9}), + spans: []automerge.SpanInput{ + {Text: "bold text", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "different_adjacent_marks", + setup: spliceSetup("bolditalic"), + spans: []automerge.SpanInput{ + {Text: "bold", Marks: marks("bold", markBool())}, + {Text: "italic", Marks: marks("italic", markBool())}, + }, + config: defaultConfig, + }, + { + name: "mark_on_empty_string", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: "", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "mark_on_whitespace", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: " ", Marks: marks("bold", markBool())}, + {Text: "\n", Marks: marks("italic", markBool())}, + }, + config: defaultConfig, + }, + { + name: "removing_all_text_from_marked_span", + setup: markSetup("hello world", markSpec{"bold", markBool(), 0, 5}), + spans: []automerge.SpanInput{ + {Text: " world"}, + }, + config: defaultConfig, + }, + { + name: "nested_marks", + setup: spliceSetup("italic bold and italic just italic"), + spans: []automerge.SpanInput{ + {Text: "italic ", Marks: marks("italic", markBool())}, + {Text: "bold and italic", Marks: marks("italic", markBool(), "bold", markBool())}, + {Text: " just italic", Marks: marks("italic", markBool())}, + }, + config: defaultConfig, + }, + { + name: "many_marks_on_same_text", + setup: spliceSetup("formatted"), + spans: []automerge.SpanInput{ + {Text: "formatted", Marks: marks( + "bold", markBool(), + "italic", markBool(), + "underline", markBool(), + "link", markStr("https://example.com"), + )}, + }, + config: defaultConfig, + }, + { + name: "mark_value_changes_link_url", + setup: markSetup("click here", markSpec{"link", markStr("https://old.com"), 0, 10}), + spans: []automerge.SpanInput{ + {Text: "click here", Marks: marks("link", markStr("https://new.com"))}, + }, + config: defaultConfig, + }, + { + name: "mark_value_changes_color", + setup: markSetup("colored", markSpec{"color", markStr("red"), 0, 7}), + spans: []automerge.SpanInput{ + {Text: "colored", Marks: marks("color", markStr("blue"))}, + }, + config: defaultConfig, + }, + { + name: "mark_value_type_changes", + setup: markSetup("text", markSpec{"custom", markBool(), 0, 4}), + spans: []automerge.SpanInput{ + {Text: "text", Marks: marks("custom", markStr("value"))}, + }, + config: defaultConfig, + }, + { + name: "marks_on_emoji", + setup: spliceSetup("Hello πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦ world"), + spans: []automerge.SpanInput{ + {Text: "Hello "}, + {Text: "πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦", Marks: marks("emoji", markBool())}, + {Text: " world"}, + }, + config: defaultConfig, + }, + { + name: "marks_on_combining_characters", + setup: spliceSetup("cafΓ©"), + spans: []automerge.SpanInput{ + {Text: "cafΓ©", Marks: marks("accented", markBool())}, + }, + config: defaultConfig, + }, + { + name: "unmark_part_of_range", + setup: markSetup("bold text here", markSpec{"bold", markBool(), 0, 14}), + spans: []automerge.SpanInput{ + {Text: "bold", Marks: marks("bold", markBool())}, + {Text: " text "}, + {Text: "here", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + { + name: "unmark_creates_gaps", + setup: markSetup("a b c d e", markSpec{"mark", markBool(), 0, 9}), + spans: []automerge.SpanInput{ + {Text: "a", Marks: marks("mark", markBool())}, + {Text: " b "}, + {Text: "c", Marks: marks("mark", markBool())}, + {Text: " d "}, + {Text: "e", Marks: marks("mark", markBool())}, + }, + config: defaultConfig, + }, + { + name: "complex_unicode_text", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: "Hello "}, + {Text: "😊", Marks: marks("emoji", markBool())}, + {Text: " δΈ–η•Œ ", Marks: marks("chinese", markBool())}, + {Text: "🌍", Marks: marks("emoji", markBool())}, + {Text: " Ω…Ψ±Ψ­Ψ¨Ψ§", Marks: marks("arabic", markBool())}, + }, + config: defaultConfig, + }, + { + name: "empty_spans_between_marks", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: "a", Marks: marks("mark", markBool())}, + {Text: ""}, + {Text: "b", Marks: marks("mark", markBool())}, + }, + config: defaultConfig, + }, + { + name: "marks_with_different_values_same_name", + setup: spliceSetup("red blue green"), + spans: []automerge.SpanInput{ + {Text: "red", Marks: marks("color", markStr("red"))}, + {Text: " "}, + {Text: "blue", Marks: marks("color", markStr("blue"))}, + {Text: " "}, + {Text: "green", Marks: marks("color", markStr("green"))}, + }, + config: defaultConfig, + }, + { + name: "marks_with_expand_none_at_boundaries", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: "text", Marks: marks("mark", markBool())}, + }, + config: automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandNone}, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "before ")) + require.NoError(t, text.Splice(ctx, 11, 0, " after")) + }, + }, + { + name: "multiple_marks_different_expand_behaviors", + setup: spliceSetup(""), + spans: []automerge.SpanInput{ + {Text: "text", Marks: marks("before", markBool(), "after", markBool(), "none", markBool())}, + }, + config: automerge.UpdateSpansConfig{ + DefaultExpand: automerge.MarkExpandAfter, + PerMarkExpands: map[string]automerge.MarkExpand{ + "before": automerge.MarkExpandBefore, + "after": automerge.MarkExpandAfter, + "none": automerge.MarkExpandNone, + }, + }, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + require.NoError(t, text.Splice(ctx, 5, 0, "b")) + }, + }, + { + name: "update_spans_which_inserts_at_the_end_of_expand_mark", + setup: markSetup("hello world", markSpec{"bold", markBool(), 6, 11}), + spans: []automerge.SpanInput{ + {Text: "hello "}, + {Text: "wworldd", Marks: marks("bold", markBool())}, + }, + config: defaultConfig, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + scenario.setup(ctx, t, text) + _, err = document.Commit(ctx, "setup", commitTime) + require.NoError(t, err) + + require.NoError(t, text.UpdateSpans(ctx, scenario.spans, scenario.config)) + _, err = document.Commit(ctx, "update", commitTime) + require.NoError(t, err) + + if scenario.post != nil { + scenario.post(ctx, t, text) + _, err = document.Commit(ctx, "post", commitTime) + require.NoError(t, err) + } + + spans, err := text.Spans(ctx) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) + }) + } +} + +// TestRustDiffMarks_Idempotent reproduces idempotent_update_spans: repeating the +// same update_spans call produces no additional changes. +func TestRustDiffMarks_Idempotent(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + spans := []automerge.SpanInput{ + {Text: "hello ", Marks: marks("bold", markBool())}, + {Text: "world", Marks: marks("italic", markBool())}, + } + config := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + _, err = document.Commit(ctx, "first", commitTime) + require.NoError(t, err) + + first, err := document.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + second, err := document.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + third, err := document.Heads(ctx) + require.NoError(t, err) + + assert.Equal(t, headHexes(first), headHexes(second)) + assert.Equal(t, headHexes(first), headHexes(third)) + }) + } +} + +// TestRustDiffMarks_Alternating reproduces alternating_mark_changes: repeatedly +// adding and removing a mark on the same text converges on the final span set. +func TestRustDiffMarks_Alternating(t *testing.T) { + t.Parallel() + + ctx := context.Background() + config := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + result := make(map[string][]automerge.Span) + + rounds := [][]automerge.SpanInput{ + {{Text: "text", Marks: marks("bold", markBool())}}, + {{Text: "text"}}, + {{Text: "text", Marks: marks("italic", markBool())}}, + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "text")) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + for index, round := range rounds { + require.NoError(t, text.UpdateSpans(ctx, round, config)) + _, err = document.Commit(ctx, "round", commitTime.Add(time.Duration(index+1)*time.Second)) + require.NoError(t, err) + } + + spans, err := text.Spans(ctx) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) +} + +type markSpec struct { + name string + value automerge.Scalar + start uint32 + end uint32 +} + +func marks(pairs ...any) map[string]automerge.Scalar { + result := make(map[string]automerge.Scalar, len(pairs)/2) + for i := 0; i+1 < len(pairs); i += 2 { + result[pairs[i].(string)] = pairs[i+1].(automerge.Scalar) + } + + return result +} + +func headHexes(heads []automerge.Hash) []string { + hexes := make([]string, len(heads)) + for i, head := range heads { + hexes[i] = head.String() + } + + return hexes +} diff --git a/pkg/automerge/diff_parity_test.go b/pkg/automerge/diff_parity_test.go new file mode 100644 index 0000000000..b2d8e97505 --- /dev/null +++ b/pkg/automerge/diff_parity_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust diff tests from automerge 0.10 +// (rust/automerge/tests/test.rs). Each builds the same document on the native +// and Rust/WASM reference engines and asserts their diff patch streams agree. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestRustDiff_LargePatchesInLists reproduces large_patches_in_lists_are_correct: +// a string list element counts as a single index, so a long run of following +// objects is indexed correctly in the diff patch stream. +func TestRustDiff_LargePatchesInLists(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + before, err := document.Heads(ctx) + require.NoError(t, err) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeString, String: "123456"})) + + for i := 1; i < 501; i++ { + inner, err := list.InsertObject(ctx, uint64(i), automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, inner.PutScalar(ctx, "a", automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i)})) + } + + _, err = document.Commit(ctx, "large", commitTime) + require.NoError(t, err) + after, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, before, after) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) + + last := result["native"][len(result["native"])-1] + assert.Equal(t, automerge.PatchPutMap, last.Action) + assert.Equal(t, "a", last.Key) + require.NotNil(t, last.Value.Scalar) + assert.Equal(t, int64(500), last.Value.Scalar.Int) +} + +// TestRustDiff_ReverseDeletionOfObjectInList reproduces +// diff_should_reverse_deletion_of_object_in_list_correctly. +func TestRustDiff_ReverseDeletionOfObjectInList(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar(ctx, 0, automerge.Scalar{Type: automerge.ScalarTypeString, String: "a"})) + text, err := list.InsertObject(ctx, 1, automerge.ObjectTypeText) + require.NoError(t, err) + textValue, err := text.Text() + require.NoError(t, err) + require.NoError(t, textValue.Splice(ctx, 0, 0, "b")) + require.NoError(t, list.InsertScalar(ctx, 2, automerge.Scalar{Type: automerge.ScalarTypeString, String: "c"})) + _, err = document.Commit(ctx, "build", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, list.DeleteIndex(ctx, 1)) + _, err = document.Commit(ctx, "delete", commitTime.Add(time.Second)) + require.NoError(t, err) + after, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, after, before) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 2) + assert.Equal(t, automerge.PatchInsert, result["native"][0].Action) + assert.Equal(t, uint64(1), result["native"][0].Index) + require.Len(t, result["native"][0].Values, 1) + assert.Equal(t, automerge.ObjectTypeText, result["native"][0].Values[0].Value.Object) + assert.Equal(t, automerge.PatchSpliceText, result["native"][1].Action) + assert.Equal(t, "b", result["native"][1].Text) +} + +// TestRustDiff_ReverseDeletionOfObjectInMap reproduces +// diff_should_reverse_deletion_of_object_in_map_correctly. +func TestRustDiff_ReverseDeletionOfObjectInMap(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + mapObject, err := document.Root().CreateObject(ctx, "map", automerge.ObjectTypeMap) + require.NoError(t, err) + _, err = mapObject.CreateObject(ctx, "text", automerge.ObjectTypeText) + require.NoError(t, err) + require.NoError(t, mapObject.PutScalar(ctx, "a", automerge.Scalar{Type: automerge.ScalarTypeString, String: "a"})) + textB, err := mapObject.CreateObject(ctx, "b", automerge.ObjectTypeText) + require.NoError(t, err) + textBValue, err := textB.Text() + require.NoError(t, err) + require.NoError(t, textBValue.Splice(ctx, 0, 0, "b")) + require.NoError(t, mapObject.PutScalar(ctx, "c", automerge.Scalar{Type: automerge.ScalarTypeString, String: "c"})) + _, err = document.Commit(ctx, "build", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, mapObject.DeleteKey(ctx, "b")) + _, err = document.Commit(ctx, "delete", commitTime.Add(time.Second)) + require.NoError(t, err) + after, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, after, before) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 2) + assert.Equal(t, automerge.PatchPutMap, result["native"][0].Action) + assert.Equal(t, "b", result["native"][0].Key) + assert.Equal(t, automerge.ObjectTypeText, result["native"][0].Value.Object) + assert.Equal(t, automerge.PatchSpliceText, result["native"][1].Action) + assert.Equal(t, "b", result["native"][1].Text) +} + +// TestRustDiff_ReverseDeletionOfBlockInText reproduces +// diff_should_reverse_deletion_of_block_in_text_correctly. +func TestRustDiff_ReverseDeletionOfBlockInText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + block, err := text.SplitBlock(ctx, 1) + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 2, 0, "b")) + require.NoError(t, block.PutScalar(ctx, "key", automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"})) + _, err = document.Commit(ctx, "build", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, text.JoinBlock(ctx, 1)) + _, err = document.Commit(ctx, "delete", commitTime.Add(time.Second)) + require.NoError(t, err) + after, err := document.Heads(ctx) + require.NoError(t, err) + + patches, err := document.Diff(ctx, after, before) + require.NoError(t, err) + + result[engine.name] = patches + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 2) + assert.Equal(t, automerge.PatchInsert, result["native"][0].Action) + assert.Equal(t, uint64(1), result["native"][0].Index) + require.Len(t, result["native"][0].Values, 1) + assert.Equal(t, automerge.ObjectTypeMap, result["native"][0].Values[0].Value.Object) + assert.Equal(t, automerge.PatchPutMap, result["native"][1].Action) + assert.Equal(t, "key", result["native"][1].Key) + require.NotNil(t, result["native"][1].Value.Scalar) + assert.Equal(t, "value", result["native"][1].Value.Scalar.String) +} diff --git a/pkg/automerge/fuzz_test.go b/pkg/automerge/fuzz_test.go index 9082cbe369..39d38b1db2 100644 --- a/pkg/automerge/fuzz_test.go +++ b/pkg/automerge/fuzz_test.go @@ -22,6 +22,7 @@ package automerge_test import ( "context" + "fmt" "testing" "go.probo.inc/probo/pkg/automerge" @@ -45,3 +46,168 @@ func FuzzLoad(f *testing.F) { _ = document.Close(context.Background()) }) } + +func FuzzCoreOperations(f *testing.F) { + f.Add([]byte{0, 1, 2, 3, 4, 5, 6, 7}) + f.Add([]byte("automerge fuzz operations")) + f.Add([]byte{255, 0, 255, 1, 254, 2, 253, 3}) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 4096 { + t.Skip() + } + + ctx := context.Background() + + document, err := automerge.New(ctx, actor(254)) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = document.Close(context.Background()) }() + + root := document.Root() + + values, err := root.CreateObject(ctx, "values", automerge.ObjectTypeMap) + if err != nil { + t.Fatal(err) + } + + list, err := root.CreateObject(ctx, "list", automerge.ObjectTypeList) + if err != nil { + t.Fatal(err) + } + + mapModel := make(map[string]int64) + + var listModel []int64 + + for index, operation := range data { + key := fmt.Sprintf("key-%d", operation%8) + value := int64(int8(operation)) + + switch operation % 5 { + case 0: + mapModel[key] = value + err = values.PutScalar( + ctx, + key, + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + }, + ) + case 1: + if _, ok := mapModel[key]; ok { + delete(mapModel, key) + err = values.DeleteKey(ctx, key) + } + case 2: + position := 0 + if len(listModel) > 0 { + position = int(operation) % (len(listModel) + 1) + } + + listModel = append(listModel, 0) + copy(listModel[position+1:], listModel[position:]) + listModel[position] = value + err = list.InsertScalar( + ctx, + uint64(position), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + }, + ) + case 3: + if len(listModel) > 0 { + position := int(operation) % len(listModel) + listModel[position] = value + err = list.PutScalarAt( + ctx, + uint64(position), + automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + }, + ) + } + case 4: + if len(listModel) > 0 { + position := int(operation) % len(listModel) + listModel = append( + listModel[:position], + listModel[position+1:]..., + ) + err = list.DeleteIndex(ctx, uint64(position)) + } + } + + if err != nil { + t.Fatalf("operation %d failed: %v", index, err) + } + } + + if _, err := document.Commit(ctx, "fuzz operations", commitTime); err != nil { + t.Fatal(err) + } + + saved, err := document.Save(ctx) + if err != nil { + t.Fatal(err) + } + + loaded, err := automerge.Load(ctx, saved, actor(253)) + if err != nil { + t.Fatal(err) + } + + defer func() { _ = loaded.Close(context.Background()) }() + + loadedValues, err := loaded.Root().Object(ctx, "values") + if err != nil { + t.Fatal(err) + } + + for key, expected := range mapModel { + value, err := loadedValues.Scalar(ctx, key) + if err != nil { + t.Fatal(err) + } + + if value.Int != expected { + t.Fatalf("map value %q is %d, expected %d", key, value.Int, expected) + } + } + + loadedList, err := loaded.Root().Object(ctx, "list") + if err != nil { + t.Fatal(err) + } + + length, err := loadedList.Len(ctx) + if err != nil { + t.Fatal(err) + } + + if length != uint64(len(listModel)) { + t.Fatalf("list length is %d, expected %d", length, len(listModel)) + } + + for index, expected := range listModel { + value, err := loadedList.ScalarAt(ctx, uint64(index)) + if err != nil { + t.Fatal(err) + } + + if value.Int != expected { + t.Fatalf( + "list value %d is %d, expected %d", + index, + value.Int, + expected, + ) + } + } + }) +} diff --git a/pkg/automerge/hydrate.go b/pkg/automerge/hydrate.go new file mode 100644 index 0000000000..9f8ffba8b5 --- /dev/null +++ b/pkg/automerge/hydrate.go @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge + +import ( + "context" + "fmt" + "slices" + "time" +) + +type ( + // ValueType identifies one hydrated Automerge value. + ValueType string + + // Value is a recursively hydrated Automerge value. + Value struct { + Type ValueType + Scalar Scalar + Map map[string]Value + List []Value + Text string + } +) + +const ( + ValueTypeScalar ValueType = "scalar" + ValueTypeMap ValueType = "map" + ValueTypeList ValueType = "list" + ValueTypeText ValueType = "text" +) + +// NewFrom creates and commits a document from a hydrated root map. +func NewFrom( + ctx context.Context, + actorID ActorID, + value map[string]Value, + message string, + timestamp time.Time, +) (*Document, error) { + return newFrom(ctx, actorID, value, message, timestamp, New) +} + +// NewReferenceFrom creates a hydrated document using the Rust/WASM oracle. +func NewReferenceFrom( + ctx context.Context, + actorID ActorID, + value map[string]Value, + message string, + timestamp time.Time, +) (*Document, error) { + return newFrom( + ctx, + actorID, + value, + message, + timestamp, + NewReference, + ) +} + +func newFrom( + ctx context.Context, + actorID ActorID, + value map[string]Value, + message string, + timestamp time.Time, + factory func(context.Context, ActorID) (*Document, error), +) (*Document, error) { + document, err := factory(ctx, actorID) + if err != nil { + return nil, err + } + + if err := document.Root().PutMap(ctx, value); err != nil { + _ = document.Close(context.Background()) + return nil, err + } + + if _, err := document.Commit(ctx, message, timestamp); err != nil { + _ = document.Close(context.Background()) + return nil, err + } + + return document, nil +} + +// PutMap assigns a batch of recursively hydrated map properties. +func (o *Object) PutMap(ctx context.Context, values map[string]Value) error { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + + slices.Sort(keys) + + for _, key := range keys { + if err := o.PutValue(ctx, key, values[key]); err != nil { + return fmt.Errorf("cannot put hydrated property %q: %w", key, err) + } + } + + return nil +} + +// PutValue assigns one recursively hydrated value to a map property. +func (o *Object) PutValue(ctx context.Context, key string, value Value) error { + switch value.Type { + case ValueTypeScalar: + return o.PutScalar(ctx, key, value.Scalar) + case ValueTypeMap: + child, err := o.CreateObject(ctx, key, ObjectTypeMap) + if err != nil { + return err + } + + return child.PutMap(ctx, value.Map) + case ValueTypeList: + child, err := o.CreateObject(ctx, key, ObjectTypeList) + if err != nil { + return err + } + + return child.InsertValues(ctx, 0, value.List) + case ValueTypeText: + child, err := o.CreateObject(ctx, key, ObjectTypeText) + if err != nil { + return err + } + + text := &Text{document: child.document, handle: child.handle} + + return text.Splice(ctx, 0, 0, value.Text) + default: + return fmt.Errorf("unknown hydrated value type %q", value.Type) + } +} + +// InsertValues inserts recursively hydrated values into a list. +func (o *Object) InsertValues( + ctx context.Context, + index uint64, + values []Value, +) error { + for offset, value := range values { + if err := o.InsertValue(ctx, index+uint64(offset), value); err != nil { + return fmt.Errorf("cannot insert hydrated value %d: %w", offset, err) + } + } + + return nil +} + +// InsertValue inserts one recursively hydrated value into a list. +func (o *Object) InsertValue( + ctx context.Context, + index uint64, + value Value, +) error { + switch value.Type { + case ValueTypeScalar: + return o.InsertScalar(ctx, index, value.Scalar) + case ValueTypeMap: + child, err := o.InsertObject(ctx, index, ObjectTypeMap) + if err != nil { + return err + } + + return child.PutMap(ctx, value.Map) + case ValueTypeList: + child, err := o.InsertObject(ctx, index, ObjectTypeList) + if err != nil { + return err + } + + return child.InsertValues(ctx, 0, value.List) + case ValueTypeText: + child, err := o.InsertObject(ctx, index, ObjectTypeText) + if err != nil { + return err + } + + text := &Text{document: child.document, handle: child.handle} + + return text.Splice(ctx, 0, 0, value.Text) + default: + return fmt.Errorf("unknown hydrated value type %q", value.Type) + } +} + +// PutValueAt replaces a list element with one recursively hydrated value. +func (o *Object) PutValueAt( + ctx context.Context, + index uint64, + value Value, +) error { + switch value.Type { + case ValueTypeScalar: + return o.PutScalarAt(ctx, index, value.Scalar) + case ValueTypeMap: + child, err := o.putObjectAt(ctx, index, ObjectTypeMap) + if err != nil { + return err + } + + return child.PutMap(ctx, value.Map) + case ValueTypeList: + child, err := o.putObjectAt(ctx, index, ObjectTypeList) + if err != nil { + return err + } + + return child.InsertValues(ctx, 0, value.List) + case ValueTypeText: + child, err := o.putObjectAt(ctx, index, ObjectTypeText) + if err != nil { + return err + } + + text := &Text{document: child.document, handle: child.handle} + + return text.Splice(ctx, 0, 0, value.Text) + default: + return fmt.Errorf("unknown hydrated value type %q", value.Type) + } +} + +// SpliceValues deletes and inserts recursively hydrated list values. +func (o *Object) SpliceValues( + ctx context.Context, + index uint64, + deleteCount uint64, + values []Value, +) error { + for range deleteCount { + if err := o.DeleteIndex(ctx, index); err != nil { + return err + } + } + + return o.InsertValues(ctx, index, values) +} + +func (o *Object) putObjectAt( + ctx context.Context, + index uint64, + objectType ObjectType, +) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + handle, err := o.document.backend.PutObjectAt( + ctx, + o.handle, + index, + string(objectType), + ) + if err != nil { + return nil, fmt.Errorf("cannot replace Automerge object: %w", err) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} diff --git a/pkg/automerge/hydrate_test.go b/pkg/automerge/hydrate_test.go new file mode 100644 index 0000000000..5e1951964f --- /dev/null +++ b/pkg/automerge/hydrate_test.go @@ -0,0 +1,319 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestDocument_HydrateMatchesReference(t *testing.T) { + t.Parallel() + + value := map[string]automerge.Value{ + "config": { + Type: automerge.ValueTypeMap, + Map: map[string]automerge.Value{ + "enabled": { + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeBoolean, + Bool: true, + }, + }, + "name": { + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: "Policy", + }, + }, + }, + }, + "items": { + Type: automerge.ValueTypeList, + List: []automerge.Value{ + { + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: 1, + }, + }, + { + Type: automerge.ValueTypeMap, + Map: map[string]automerge.Value{ + "nested": { + Type: automerge.ValueTypeText, + Text: "AπŸ˜€B", + }, + }, + }, + { + Type: automerge.ValueTypeList, + List: []automerge.Value{ + { + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: "deep", + }, + }, + }, + }, + }, + }, + "text": { + Type: automerge.ValueTypeText, + Text: "Hello", + }, + } + + ctx := context.Background() + nativeDocument, err := automerge.NewFrom( + ctx, + actor(163), + value, + "hydrate", + commitTime, + ) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReferenceFrom( + ctx, + actor(163), + value, + "hydrate", + commitTime, + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeHeads, err := nativeDocument.Heads(ctx) + require.NoError(t, err) + referenceHeads, err := referenceDocument.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, referenceHeads, nativeHeads) + assertHydratedDocument(t, ctx, nativeDocument) + assertHydratedDocument(t, ctx, referenceDocument) + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(164), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + assertHydratedDocument(t, ctx, referenceFromNative) +} + +func TestDocument_HydrateRollback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(165)) + require.NoError(t, err) + closeDocument(t, document) + require.NoError(t, document.Root().PutMap( + ctx, + map[string]automerge.Value{ + "value": { + Type: automerge.ValueTypeList, + List: []automerge.Value{ + { + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: 1, + }, + }, + }, + }, + }, + )) + cancelled, err := document.Rollback(ctx) + require.NoError(t, err) + assert.Positive(t, cancelled) + + _, err = document.Root().Object(ctx, "value") + require.Error(t, err) +} + +func TestDocument_HydrateSpliceMatchesReference(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + map[string]automerge.Value, + string, + time.Time, + ) (*automerge.Document, error){ + "native": automerge.NewFrom, + "reference": automerge.NewReferenceFrom, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory( + ctx, + actor(166), + map[string]automerge.Value{ + "list": { + Type: automerge.ValueTypeList, + List: []automerge.Value{ + hydratedInt(1), + hydratedInt(2), + hydratedInt(3), + }, + }, + }, + "initial", + commitTime, + ) + require.NoError(t, err) + closeDocument(t, document) + list, err := document.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, list.SpliceValues( + ctx, + 1, + 1, + []automerge.Value{ + { + Type: automerge.ValueTypeMap, + Map: map[string]automerge.Value{ + "value": hydratedInt(4), + }, + }, + {Type: automerge.ValueTypeText, Text: "text"}, + }, + )) + require.NoError(t, list.PutValueAt( + ctx, + 3, + automerge.Value{ + Type: automerge.ValueTypeList, + List: []automerge.Value{hydratedInt(5)}, + }, + )) + _, err = document.Commit( + ctx, + "splice", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(4), length) + + first, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(1), first.Int) + + nested, err := list.ObjectAt(ctx, 1) + require.NoError(t, err) + nestedValue, err := nested.Scalar(ctx, "value") + require.NoError(t, err) + assert.Equal(t, int64(4), nestedValue.Int) + + textObject, err := list.ObjectAt(ctx, 2) + require.NoError(t, err) + text, err := textObject.Text() + require.NoError(t, err) + textValue, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "text", textValue) + + nestedList, err := list.ObjectAt(ctx, 3) + require.NoError(t, err) + last, err := nestedList.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(5), last.Int) + }) + } +} + +func hydratedInt(value int64) automerge.Value { + return automerge.Value{ + Type: automerge.ValueTypeScalar, + Scalar: automerge.Scalar{ + Type: automerge.ScalarTypeInt, + Int: value, + }, + } +} + +func assertHydratedDocument( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) { + t.Helper() + + config, err := document.Root().Object(ctx, "config") + require.NoError(t, err) + enabled, err := config.Scalar(ctx, "enabled") + require.NoError(t, err) + assert.True(t, enabled.Bool) + + name, err := config.Scalar(ctx, "name") + require.NoError(t, err) + assert.Equal(t, "Policy", name.String) + + items, err := document.Root().Object(ctx, "items") + require.NoError(t, err) + length, err := items.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(3), length) + + first, err := items.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(1), first.Int) + + nestedMap, err := items.ObjectAt(ctx, 1) + require.NoError(t, err) + nestedTextObject, err := nestedMap.Object(ctx, "nested") + require.NoError(t, err) + nestedText, err := nestedTextObject.Text() + require.NoError(t, err) + nestedValue, err := nestedText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AπŸ˜€B", nestedValue) + + text, err := document.Text(ctx, "text") + require.NoError(t, err) + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Hello", value) +} diff --git a/pkg/automerge/internal/native/backend.go b/pkg/automerge/internal/native/backend.go index 409fb1a700..cfce24e350 100644 --- a/pkg/automerge/internal/native/backend.go +++ b/pkg/automerge/internal/native/backend.go @@ -25,18 +25,22 @@ import ( "context" "crypto/rand" "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" + "math" "sort" + "strings" "time" - "unicode/utf16" ) type Backend struct { state *State actor ActorID + nextOp uint64 base []byte appended [][]byte + saveCursor int pending []Operation objects map[uint32]ObjectID nextHandle uint32 @@ -44,19 +48,58 @@ type Backend struct { nextSyncState uint32 queuedChanges map[ChangeHash]*Change queuedBytes int + diffCursor [][32]byte + + // isolation pins reads and writes to a historical frontier. While active, + // state points at a view built from the isolation heads and fullState keeps + // the complete history; committed isolated changes are applied to both, while + // merged changes are applied only to fullState. + isolationActive bool + fullState *State + baseActor ActorID + + // isolationDiffTargets records the frontiers isolated to since the diff + // cursor was last set. When present, an incremental diff replays the + // transition from the cursor down to each isolation frontier and back up to + // the current heads, matching the reference's patch-log output across + // isolate/integrate rather than a direct state comparison. + isolationDiffTargets [][][32]byte } type nativeSyncState struct { - RemoteHeads [][32]byte `json:"remoteHeads"` - Need [][32]byte `json:"need"` - Requested [][32]byte `json:"requested"` - NeedsAck bool `json:"needsAck"` - InFlight bool `json:"inFlight"` + RemoteHeads [][32]byte `json:"remoteHeads"` + LastSentHeads [][32]byte `json:"lastSentHeads"` + Need [][32]byte `json:"need"` + Requested [][32]byte `json:"requested"` + NeedsAck bool `json:"needsAck"` + InFlight bool `json:"inFlight"` + Sent bool `json:"sent"` + ReadOnly bool `json:"readOnly"` + PeerReadOnly bool `json:"peerReadOnly"` + PeerModeChanged bool `json:"peerModeChanged"` + PeerSupportsReset bool `json:"peerSupportsReset"` + NeedsReset bool `json:"needsReset"` + ModeChanged bool `json:"modeChanged"` +} + +type scalarWire struct { + Type string `json:"type"` + Bool bool `json:"bool"` + Uint uint64 `json:"uint"` + Int int64 `json:"int"` + Float uint64 `json:"floatBits"` + String string `json:"string"` + Bytes string `json:"bytes"` } const ( maxQueuedChangeBytes = 64 * 1024 * 1024 maxQueuedChanges = 100_000 + + syncFlagReset = 1 << 0 + syncFlagReadOnly = 1 << 1 + syncFlagSupportsReset = 1 << 2 + syncFlagMarker = 0x80 ) func NewBackend(ctx context.Context) (*Backend, error) { @@ -84,6 +127,7 @@ func NewBackend(ctx context.Context) (*Backend, error) { return &Backend{ state: state, actor: actor, + nextOp: state.maxOpGlobal() + 1, base: base, objects: map[uint32]ObjectID{0: RootObject()}, nextHandle: 1, @@ -100,6 +144,15 @@ func LoadBackend(ctx context.Context, data []byte) (*Backend, error) { document, err := Decode(data) if err != nil { + // A document may retain orphan changes (changes whose dependencies are + // not present) that were preserved across a save. Strict decoding + // rejects them, so fall back to a tolerant load that applies every + // change whose dependencies are satisfiable and queues the rest. A load + // that cannot apply a single change (a bare orphan) still fails. + if backend, ok, tolerantErr := loadBackendRetainingOrphans(data, err); ok { + return backend, tolerantErr + } + return nil, fmt.Errorf("cannot decode native document: %w", err) } @@ -116,6 +169,7 @@ func LoadBackend(ctx context.Context, data []byte) (*Backend, error) { return &Backend{ state: state, actor: actor, + nextOp: state.maxOpGlobal() + 1, base: append([]byte(nil), data...), objects: map[uint32]ObjectID{0: RootObject()}, nextHandle: 1, @@ -125,11 +179,144 @@ func LoadBackend(ctx context.Context, data []byte) (*Backend, error) { }, nil } +// loadBackendRetainingOrphans attempts a tolerant load for documents that carry +// orphan changes. It returns ok=false when the tolerant path does not apply (the +// data is corrupt beyond missing dependencies, or nothing can be applied), so +// the caller reports the original strict error. On success the applied history +// forms the base and the orphan changes are queued for later resolution. +func loadBackendRetainingOrphans( + data []byte, + strictErr error, +) (*Backend, bool, error) { + if !strings.Contains(strictErr.Error(), "missing dependency") { + return nil, false, nil + } + + document, err := DecodePartial(data) + if err != nil { + return nil, false, nil + } + + state := NewState() + queued := make(map[ChangeHash]*Change, len(document.Changes)) + + for i := range document.Changes { + change := &document.Changes[i] + if change.Hash == nil || len(change.Raw) == 0 { + return nil, false, nil + } + + queued[*change.Hash] = change + } + + applied := make([]*Change, 0, len(document.Changes)) + + for { + progressed := false + + for _, change := range orderedQueuedChanges(queued) { + if !state.hasDependencies(change) { + continue + } + + if err := state.ApplyChange(change); err != nil { + return nil, false, nil + } + + applied = append(applied, change) + delete(queued, *change.Hash) + + progressed = true + } + + if !progressed { + break + } + } + + if len(applied) == 0 { + return nil, false, nil + } + + actor, err := randomActorID() + if err != nil { + return nil, true, err + } + + base := make([]byte, 0, len(data)) + for _, change := range applied { + base = append(base, change.Raw...) + } + + queuedClone := make(map[ChangeHash]*Change, len(queued)) + queuedBytes := 0 + + for hash, change := range queued { + clone := *change + clone.Raw = append([]byte(nil), change.Raw...) + queuedClone[hash] = &clone + queuedBytes += len(clone.Raw) + } + + return &Backend{ + state: state, + actor: actor, + nextOp: state.maxOpGlobal() + 1, + base: base, + objects: map[uint32]ObjectID{0: RootObject()}, + nextHandle: 1, + syncStates: make(map[uint32]*nativeSyncState), + nextSyncState: 1, + queuedChanges: queuedClone, + queuedBytes: queuedBytes, + }, true, nil +} + +// orderedQueuedChanges returns queued changes in a deterministic order (by hash) +// so tolerant loading applies and re-serializes changes reproducibly. +func orderedQueuedChanges(queued map[ChangeHash]*Change) []*Change { + changes := make([]*Change, 0, len(queued)) + for _, change := range queued { + changes = append(changes, change) + } + + sort.Slice(changes, func(i, j int) bool { + return bytes.Compare(changes[i].Hash[:], changes[j].Hash[:]) < 0 + }) + + return changes +} + func (b *Backend) Close(context.Context) error { return nil } func (b *Backend) Save(ctx context.Context) ([]byte, error) { + return b.save(ctx, true, true) +} + +// SaveNoCompress serializes the document without DEFLATE-compressing any change +// chunks, mirroring Rust's AutoCommit::save_nocompress. +func (b *Backend) SaveNoCompress(ctx context.Context) ([]byte, error) { + return b.save(ctx, true, false) +} + +// SaveWithOptions serializes the document, optionally appending retained orphan +// changes (queued changes whose dependencies are still missing) so they survive +// a save/load round trip. It mirrors the Rust SaveOptions.retain_orphans flag; +// the reference retains orphans by default. +func (b *Backend) SaveWithOptions( + ctx context.Context, + retainOrphans bool, +) ([]byte, error) { + return b.save(ctx, retainOrphans, true) +} + +func (b *Backend) save( + ctx context.Context, + retainOrphans bool, + deflate bool, +) ([]byte, error) { if len(b.pending) > 0 { if _, err := b.Commit(ctx, "", time.Time{}); err != nil { return nil, err @@ -142,15 +329,113 @@ func (b *Backend) Save(ctx context.Context) ([]byte, error) { } data := make([]byte, 0, total) - data = append(data, b.base...) + for _, change := range b.appended { + data = append(data, maybeCompressChangeChunk(change, deflate)...) + } + + b.saveCursor = len(b.appended) + + if retainOrphans { + for _, change := range orderedQueuedChanges(b.queuedChanges) { + data = append(data, maybeCompressChangeChunk(change.Raw, deflate)...) + } + } + + return data, nil +} + +// deflateMinSize matches Rust's change::DEFLATE_MIN_SIZE: change chunks whose +// body is at least this many bytes are worth compressing. +const deflateMinSize = 250 + +// maybeCompressChangeChunk reframes an uncompressed change chunk as a compressed +// change chunk when compression is requested and the body is large enough to +// benefit. The 4-byte checksum is preserved because the reference (and native +// decoder) recompute the hash from the inflated body. Any other chunk kind, a +// small body, or a non-shrinking result is returned unchanged. +func maybeCompressChangeChunk(raw []byte, deflateEnabled bool) []byte { + const headerSize = 9 // 4 magic + 4 checksum + 1 type + + if !deflateEnabled || len(raw) <= headerSize || ChunkType(raw[8]) != ChunkChange { + return raw + } + + reader := &reader{data: raw, offset: headerSize} + + bodyLength, err := reader.uleb() + if err != nil || reader.offset+int(bodyLength) > len(raw) { + return raw + } + + body := raw[reader.offset : reader.offset+int(bodyLength)] + if len(body) < deflateMinSize { + return raw + } + + compressed, err := deflate(body) + if err != nil || len(compressed) >= len(body) { + return raw + } + + out := make([]byte, 0, headerSize+len(compressed)+8) + out = append(out, raw[:8]...) + out = append(out, byte(ChunkCompressedChange)) + out = appendULEB(out, uint64(len(compressed))) + out = append(out, compressed...) + + return out +} + +func (b *Backend) SaveIncremental(ctx context.Context) ([]byte, error) { + if len(b.pending) > 0 { + if _, err := b.Commit(ctx, "", time.Time{}); err != nil { + return nil, err + } + } + + if b.saveCursor > len(b.appended) { + b.saveCursor = len(b.appended) + } + + total := 0 + for _, change := range b.appended[b.saveCursor:] { + total += len(change) + } + + data := make([]byte, 0, total) + for _, change := range b.appended[b.saveCursor:] { data = append(data, change...) } + b.saveCursor = len(b.appended) + return data, nil } +func (b *Backend) LoadIncremental( + ctx context.Context, + data []byte, +) (uint64, error) { + _, consumed, err := DecodeIncremental(data) + if err != nil { + return 0, err + } + + before := len(b.state.changes) + if _, err := b.Merge(ctx, data[:consumed]); err != nil { + return 0, err + } + + after := len(b.state.changes) + if after < before { + return 0, fmt.Errorf("incremental load reduced the change count") + } + + return uint64(after - before), nil +} + func (b *Backend) SetActor(ctx context.Context, value []byte) error { if err := ctx.Err(); err != nil { return err @@ -180,6 +465,13 @@ func (b *Backend) PutString( return err } + if existing, ok := b.state.visibleMapOperation(key, ActionSet); ok && + existing.Value != nil && + existing.Value.Type == ScalarString && + existing.Value.String == value { + return nil + } + property := key operation := Operation{ @@ -196,269 +488,3091 @@ func (b *Backend) PutString( return b.addPending(operation) } -func (b *Backend) PutText( +func (b *Backend) GetString( ctx context.Context, object uint32, key string, -) (uint32, error) { +) (string, error) { if err := b.requireRoot(ctx, object); err != nil { - return 0, err - } - - property := key - - operation := Operation{ - ID: b.nextOperationID(), - Object: RootObject(), - Key: Key{Property: &property}, - Action: ActionMakeText, - } - for _, predecessor := range b.state.visibleMapOperations(key) { - operation.Predecessors = append(operation.Predecessors, predecessor.ID) + return "", err } - if err := b.addPending(operation); err != nil { - return 0, err + operation, ok := b.state.visibleMapOperation(key, ActionSet) + if !ok || operation.Value == nil || operation.Value.Type != ScalarString { + return "", fmt.Errorf("string property %q does not exist", key) } - return b.pushObject(ObjectID{OpID: operation.ID}), nil + return operation.Value.String, nil } -func (b *Backend) GetText( +func (b *Backend) PutScalar( ctx context.Context, object uint32, key string, -) (uint32, error) { - if err := b.requireRoot(ctx, object); err != nil { - return 0, err - } - - operation, ok := b.state.visibleMapOperation(key, ActionMakeText) - if !ok { - return 0, fmt.Errorf("text property %q does not exist", key) - } - - return b.pushObject(ObjectID{OpID: operation.ID}), nil -} - -func (b *Backend) SpliceText( - ctx context.Context, - handle uint32, - index uint32, - deleteCount int32, - value string, + encoded []byte, ) error { if err := ctx.Err(); err != nil { return err } - if deleteCount < 0 { - return fmt.Errorf("negative text deletion is unsupported") - } - - object, err := b.object(handle) + objectID, err := b.mapObject(object) if err != nil { return err } - sequence := b.state.sequence(object.OpID) - - start, end, previous, err := sequenceRange(sequence, index, uint32(deleteCount)) + value, err := decodeScalarWire(encoded) if err != nil { return err } - for _, target := range sequence[start:end] { - operation := Operation{ - ID: b.nextOperationID(), - Object: object, - Key: Key{Element: new(target.ID)}, - Action: ActionDelete, - Predecessors: []OpID{target.ID}, - } - if err := b.addPending(operation); err != nil { - return err + if existing, ok := b.state.visibleMapObjectValue(objectID, key); ok { + existingValue, scalar := b.state.scalarValue(existing) + if scalar && scalarValuesEqual(existingValue, value) { + return nil } } - for _, character := range value { - key := Key{IsHead: previous == nil} - if previous != nil { - key.Element = new(*previous) - } - - operation := Operation{ - ID: b.nextOperationID(), - Object: object, - Key: key, - Insert: true, - Action: ActionSet, - Value: &Scalar{Type: ScalarString, String: string(character)}, - } - if err := b.addPending(operation); err != nil { - return err - } + property := key - previous = new(operation.ID) + operation := Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Property: &property}, + Action: ActionSet, + Value: &value, + } + for _, predecessor := range b.state.visibleMapObjectOperations(objectID, key) { + operation.Predecessors = append(operation.Predecessors, predecessor.ID) } - return nil + return b.addPending(operation) } -func (b *Backend) Text(ctx context.Context, handle uint32) (string, error) { +func (b *Backend) GetScalar( + ctx context.Context, + object uint32, + key string, +) ([]byte, error) { if err := ctx.Err(); err != nil { - return "", err + return nil, err } - object, err := b.object(handle) + objectID, err := b.mapObject(object) if err != nil { - return "", err + return nil, err } - for property, operation := range b.rootTextObjects() { - if operation.ID == object.OpID { - return b.state.Text(property) - } + operation, ok := b.state.visibleMapObjectValue(objectID, key) + if !ok { + return nil, fmt.Errorf("scalar property %q does not exist", key) } - return "", fmt.Errorf("text object does not exist") + value, ok := b.state.scalarValue(operation) + if !ok { + return nil, fmt.Errorf("map value %q is not a scalar", key) + } + + return encodeScalarWire(value) } -func (b *Backend) TextSpans( +func (b *Backend) GetScalarAtHeads( ctx context.Context, - handle uint32, + object uint32, + key string, + heads [][32]byte, ) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } - object, err := b.object(handle) + objectID, err := b.mapObject(object) if err != nil { return nil, err } - spans, err := b.state.RichTextSpans(object.OpID) - if err != nil { - return nil, err + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return nil, fmt.Errorf("historical heads are unknown") } - data, err := json.Marshal(spans) - if err != nil { - return nil, fmt.Errorf("cannot encode native rich-text spans: %w", err) + operation, ok := historical.visibleMapObjectValue(objectID, key) + if !ok { + return nil, fmt.Errorf("scalar property %q does not exist", key) } - return data, nil + value, ok := historical.scalarValue(operation) + if !ok { + return nil, fmt.Errorf("map value %q is not a scalar", key) + } + + return encodeScalarWire(value) } -func (b *Backend) TextCursor( +func (b *Backend) GetAllScalars( ctx context.Context, - handle uint32, - index uint32, + object uint32, + key string, ) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } - object, err := b.object(handle) + objectID, err := b.mapObject(object) if err != nil { return nil, err } - sequence := b.state.sequence(object.OpID) + var values []json.RawMessage - position := uint32(0) - for _, operation := range sequence { - if position == index { - data := []byte{1, 3} + for _, operation := range b.state.visibleMapObjectOperations(objectID, key) { + if operation.Action == ActionIncrement { + continue + } + + value, ok := b.state.scalarValue(operation) + if !ok { + continue + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return nil, err + } + + values = append(values, json.RawMessage(encoded)) + } + + if len(values) == 0 { + return nil, fmt.Errorf("scalar property %q does not exist", key) + } + + encoded, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("cannot encode scalar conflicts: %w", err) + } + + return encoded, nil +} + +func (b *Backend) GetAllScalarsAt( + ctx context.Context, + object uint32, + index uint64, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + sequenceObject, err := b.sequenceObject(object) + if err != nil { + return nil, err + } + + conflicts, ok := b.state.sequenceConflicts(sequenceObject.OpID, index) + if !ok { + return nil, fmt.Errorf("sequence value at index %d does not exist", index) + } + + var values []json.RawMessage + + for _, operation := range conflicts { + value, ok := b.state.scalarValue(operation) + if !ok { + continue + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return nil, err + } + + values = append(values, json.RawMessage(encoded)) + } + + if len(values) == 0 { + return nil, fmt.Errorf("sequence value at index %d is not a scalar", index) + } + + encoded, err := json.Marshal(values) + if err != nil { + return nil, fmt.Errorf("cannot encode sequence scalar conflicts: %w", err) + } + + return encoded, nil +} + +func (b *Backend) PutObject( + ctx context.Context, + object uint32, + key string, + rawType string, +) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + objectID, err := b.mapObject(object) + if err != nil { + return 0, err + } + + action, err := objectAction(rawType) + if err != nil { + return 0, err + } + + property := key + + operation := Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Property: &property}, + Action: action, + } + for _, predecessor := range b.state.visibleMapObjectOperations(objectID, key) { + operation.Predecessors = append(operation.Predecessors, predecessor.ID) + } + + if err := b.addPending(operation); err != nil { + return 0, err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) GetObject( + ctx context.Context, + object uint32, + key string, +) (uint32, string, error) { + if err := ctx.Err(); err != nil { + return 0, "", err + } + + objectID, err := b.mapObject(object) + if err != nil { + return 0, "", err + } + + operation, ok := b.state.visibleMapObjectValue(objectID, key) + if !ok { + return 0, "", fmt.Errorf("object property %q does not exist", key) + } + + rawType, err := actionObjectType(operation.Action) + if err != nil { + return 0, "", err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), rawType, nil +} + +func (b *Backend) InsertScalar( + ctx context.Context, + object uint32, + index uint64, + encoded []byte, +) error { + value, err := decodeScalarWire(encoded) + if err != nil { + return err + } + + _, err = b.insertSequenceOperation(ctx, object, index, ActionSet, &value) + + return err +} + +func (b *Backend) PutScalarAt( + ctx context.Context, + object uint32, + index uint64, + encoded []byte, +) error { + value, err := decodeScalarWire(encoded) + if err != nil { + return err + } + + target, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return err + } + + objectID, err := b.object(object) + if err != nil { + return err + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Element: new(target.Element)}, + Action: ActionSet, + Value: &value, + Predecessors: b.sequenceElementPredecessors(target.Element), + }) +} + +func (b *Backend) InsertObject( + ctx context.Context, + object uint32, + index uint64, + rawType string, +) (uint32, error) { + action, err := objectAction(rawType) + if err != nil { + return 0, err + } + + operation, err := b.insertSequenceOperation( + ctx, + object, + index, + action, + nil, + ) + if err != nil { + return 0, err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) PutObjectAt( + ctx context.Context, + object uint32, + index uint64, + rawType string, +) (uint32, error) { + action, err := objectAction(rawType) + if err != nil { + return 0, err + } + + target, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return 0, err + } + + objectID, err := b.object(object) + if err != nil { + return 0, err + } + + operation := Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Element: new(target.Element)}, + Action: action, + Predecessors: b.sequenceElementPredecessors(target.Element), + } + if err := b.addPending(operation); err != nil { + return 0, err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) GetScalarAt( + ctx context.Context, + object uint32, + index uint64, +) ([]byte, error) { + operation, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return nil, err + } + + value, ok := b.state.scalarValue(operation.Operation) + if !ok { + return nil, fmt.Errorf("sequence value at index %d is not a scalar", index) + } + + return encodeScalarWire(value) +} + +func (b *Backend) GetObjectAt( + ctx context.Context, + object uint32, + index uint64, +) (uint32, string, error) { + operation, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return 0, "", err + } + + rawType, err := actionObjectType(operation.Operation.Action) + if err != nil { + return 0, "", err + } + + return b.pushObject(ObjectID{OpID: operation.Operation.ID}), rawType, nil +} + +func (b *Backend) DeleteMap( + ctx context.Context, + object uint32, + key string, +) error { + if err := ctx.Err(); err != nil { + return err + } + + objectID, err := b.mapObject(object) + if err != nil { + return err + } + + property := key + + operation := Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Property: &property}, + Action: ActionDelete, + } + for _, predecessor := range b.state.visibleMapObjectOperations(objectID, key) { + operation.Predecessors = append(operation.Predecessors, predecessor.ID) + } + + if len(operation.Predecessors) == 0 { + return fmt.Errorf("map property %q does not exist", key) + } + + return b.addPending(operation) +} + +func (b *Backend) DeleteSequence( + ctx context.Context, + object uint32, + index uint64, +) error { + target, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return err + } + + objectID, err := b.object(object) + if err != nil { + return err + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Element: new(target.Element)}, + Action: ActionDelete, + Predecessors: b.sequenceElementPredecessors(target.Element), + }) +} + +func (b *Backend) Increment( + ctx context.Context, + object uint32, + key string, + delta int64, +) error { + if err := ctx.Err(); err != nil { + return err + } + + objectID, err := b.mapObject(object) + if err != nil { + return err + } + + visible := b.state.visibleMapObjectOperations(objectID, key) + + hasCounter := false + + for _, operation := range visible { + if isCounterOperation(operation) { + hasCounter = true + + break + } + } + + if !hasCounter { + return fmt.Errorf("map property %q is not a counter", key) + } + + property := key + + predecessors := make([]OpID, 0, len(visible)) + for _, operation := range visible { + predecessors = append(predecessors, operation.ID) + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Property: &property}, + Action: ActionIncrement, + Value: &Scalar{Type: ScalarInt, Int: delta}, + Predecessors: predecessors, + }) +} + +func (b *Backend) IncrementAt( + ctx context.Context, + object uint32, + index uint64, + delta int64, +) error { + target, err := b.sequenceOperation(ctx, object, index) + if err != nil { + return err + } + + visible := b.state.visibleSequenceElementOperations(target.Element) + + hasCounter := false + + for _, operation := range visible { + if isCounterOperation(operation) { + hasCounter = true + + break + } + } + + if !hasCounter { + return fmt.Errorf("sequence value at index %d is not a counter", index) + } + + objectID, err := b.object(object) + if err != nil { + return err + } + + predecessors := make([]OpID, 0, len(visible)) + for _, operation := range visible { + predecessors = append(predecessors, operation.ID) + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Element: new(target.Element)}, + Action: ActionIncrement, + Value: &Scalar{Type: ScalarInt, Int: delta}, + Predecessors: predecessors, + }) +} + +func (b *Backend) Keys(ctx context.Context, object uint32) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + objectID, err := b.mapObject(object) + if err != nil { + return nil, err + } + + return b.state.mapKeys(objectID), nil +} + +func (b *Backend) Length(ctx context.Context, object uint32) (uint64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + objectID, err := b.object(object) + if err != nil { + return 0, err + } + + if objectID.IsRoot { + return b.state.mapLength(objectID), nil + } + + operation, ok := b.state.operations[objectID.OpID] + if !ok { + return 0, fmt.Errorf("object does not exist") + } + + if operation.Action == ActionMakeMap || + operation.Action == ActionMakeTable { + return b.state.mapLength(objectID), nil + } + + if operation.Action != ActionMakeList && + operation.Action != ActionMakeText { + return 0, fmt.Errorf("object does not have a length") + } + + sequence := b.state.sequenceValues(objectID.OpID) + + if operation.Action == ActionMakeText { + total := uint64(0) + for _, value := range sequence { + total += sequenceValueUTF16Width(value) + } + + return total, nil + } + + return uint64(len(sequence)), nil +} + +func (b *Backend) PutText( + ctx context.Context, + object uint32, + key string, +) (uint32, error) { + if err := b.requireRoot(ctx, object); err != nil { + return 0, err + } + + property := key + + operation := Operation{ + ID: b.nextOperationID(), + Object: RootObject(), + Key: Key{Property: &property}, + Action: ActionMakeText, + } + for _, predecessor := range b.state.visibleMapOperations(key) { + operation.Predecessors = append(operation.Predecessors, predecessor.ID) + } + + if err := b.addPending(operation); err != nil { + return 0, err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) GetText( + ctx context.Context, + object uint32, + key string, +) (uint32, error) { + if err := b.requireRoot(ctx, object); err != nil { + return 0, err + } + + operation, ok := b.state.visibleMapOperation(key, ActionMakeText) + if !ok { + return 0, fmt.Errorf("text property %q does not exist", key) + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) SpliceText( + ctx context.Context, + handle uint32, + index uint32, + deleteCount int32, + value string, +) error { + if err := ctx.Err(); err != nil { + return err + } + + if deleteCount < 0 { + return fmt.Errorf("negative text deletion is unsupported") + } + + object, err := b.textObject(handle) + if err != nil { + return err + } + + // Splice positions share the unified rich-text index space with marks and + // blocks, so walk the full visible element sequence (text and block markers) + // rather than the text-only view. + sequence := b.state.sequenceElements(object.OpID) + + start, end, previous, err := sequenceRange(sequence, index, uint32(deleteCount)) + if err != nil { + return err + } + + for _, target := range sequence[start:end] { + operation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: Key{Element: new(target.ID)}, + Action: ActionDelete, + Predecessors: []OpID{target.ID}, + } + if err := b.addPending(operation); err != nil { + return err + } + } + + for _, character := range value { + key := Key{IsHead: previous == nil} + if previous != nil { + key.Element = new(*previous) + } + + operation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: key, + Insert: true, + Action: ActionSet, + Value: &Scalar{Type: ScalarString, String: string(character)}, + } + if err := b.addPending(operation); err != nil { + return err + } + + previous = new(operation.ID) + } + + return nil +} + +type ( + updateSpanInput struct { + Type string `json:"type"` + Text string `json:"text"` + Marks map[string]json.RawMessage `json:"marks"` + Block json.RawMessage `json:"block"` + } + + updateSpansConfigInput struct { + DefaultExpand string `json:"defaultExpand"` + PerMarkExpands map[string]string `json:"perMarkExpands"` + } + + desiredMark struct { + name string + value Scalar + start uint32 + end uint32 + } +) + +// UpdateSpans transforms the text object so its spans equal the supplied spans, +// mirroring the Rust AutoCommit::update_spans helper. The text content is +// reconciled with a minimal grapheme diff and the marks are then set to exactly +// the marks named on the spans, honoring the per-mark and default expand config. +// Block spans are not yet supported. +func (b *Backend) UpdateSpans( + ctx context.Context, + handle uint32, + spans []byte, + config []byte, +) error { + if err := ctx.Err(); err != nil { + return err + } + + object, err := b.textObject(handle) + if err != nil { + return err + } + + var inputs []updateSpanInput + if err := json.Unmarshal(spans, &inputs); err != nil { + return fmt.Errorf("cannot decode update spans: %w", err) + } + + var configuration updateSpansConfigInput + if err := json.Unmarshal(config, &configuration); err != nil { + return fmt.Errorf("cannot decode update spans config: %w", err) + } + + target, err := targetBlockGraphemes(inputs) + if err != nil { + return err + } + + current := b.currentBlockGraphemes(object) + + hook := &blockDiffHook{ + ctx: ctx, + backend: b, + handle: handle, + old: current, + new: target, + } + myersDiff(hook, blockTokens(current), blockTokens(target)) + + if hook.err != nil { + return hook.err + } + + desired, err := desiredMarks(inputs) + if err != nil { + return err + } + + return b.reconcileMarks(ctx, handle, object, desired, configuration) +} + +// blockOrGrapheme is one unit of the block-aware span diff: either a block +// marker with its attributes or a single grapheme cluster of text. +type blockOrGrapheme struct { + block map[string]any + isBlock bool + grapheme string +} + +func (item blockOrGrapheme) width() int { + if item.isBlock { + return 1 + } + + return utf16Width(item.grapheme) +} + +// blockTokens renders each unit to a comparison token so the grapheme-based +// Myers diff can compare blocks by their attributes and text by its clusters. +func blockTokens(items []blockOrGrapheme) []string { + tokens := make([]string, len(items)) + + for i, item := range items { + if !item.isBlock { + tokens[i] = "g" + item.grapheme + + continue + } + + encoded, _ := json.Marshal(item.block) + tokens[i] = "b" + string(encoded) + } + + return tokens +} + +// currentBlockGraphemes materializes the text object as the block/grapheme units +// the diff operates on: block markers become blocks and text runs are split into +// grapheme clusters. +func (b *Backend) currentBlockGraphemes(object ObjectID) []blockOrGrapheme { + items := make([]blockOrGrapheme, 0) + + var run strings.Builder + + flush := func() { + if run.Len() == 0 { + return + } + + for _, grapheme := range graphemeClusters(run.String()) { + items = append(items, blockOrGrapheme{grapheme: grapheme}) + } + + run.Reset() + } + + for _, value := range b.state.sequenceValues(object.OpID) { + operation := value.Operation + + if operation.Action == ActionMakeMap { + flush() + + attributes, err := b.state.mapValue(operation.ID, make(map[OpID]struct{})) + if err != nil || attributes == nil { + attributes = map[string]any{} + } + + items = append(items, blockOrGrapheme{isBlock: true, block: attributes}) + + continue + } + + if operation.Value != nil && operation.Value.Type == ScalarString { + run.WriteString(operation.Value.String) + } + } + + flush() + + return items +} + +// targetBlockGraphemes converts the requested spans into block/grapheme units. +func targetBlockGraphemes(spans []updateSpanInput) ([]blockOrGrapheme, error) { + items := make([]blockOrGrapheme, 0) + + for _, span := range spans { + switch span.Type { + case "text": + for _, grapheme := range graphemeClusters(span.Text) { + items = append(items, blockOrGrapheme{grapheme: grapheme}) + } + case "block": + attributes := map[string]any{} + if len(span.Block) > 0 { + if err := json.Unmarshal(span.Block, &attributes); err != nil { + return nil, fmt.Errorf("cannot decode block span: %w", err) + } + } + + items = append(items, blockOrGrapheme{isBlock: true, block: attributes}) + default: + return nil, fmt.Errorf("unsupported update span type %q", span.Type) + } + } + + return items, nil +} + +// blockDiffHook applies the block-aware Myers edit script, splicing text and +// splitting, joining, or rewriting block markers as required. +type blockDiffHook struct { + ctx context.Context + backend *Backend + handle uint32 + old []blockOrGrapheme + new []blockOrGrapheme + idx int + err error +} + +func (h *blockDiffHook) failed() bool { + return h.err != nil +} + +func (h *blockDiffHook) equal(oldIndex, _ int, length int) { + for i := 0; i < length; i++ { + h.idx += h.old[oldIndex+i].width() + } +} + +func (h *blockDiffHook) delete(oldIndex, oldLen, _ int) { + for i := 0; i < oldLen && h.err == nil; i++ { + item := h.old[oldIndex+i] + if item.isBlock { + h.err = h.backend.JoinBlock(h.ctx, h.handle, uint32(h.idx)) + + continue + } + + h.err = h.backend.SpliceText(h.ctx, h.handle, uint32(h.idx), int32(item.width()), "") + } +} + +func (h *blockDiffHook) insert(_ int, newIndex, newLen int) { + var run strings.Builder + + flush := func() { + if run.Len() == 0 || h.err != nil { + return + } + + chars := run.String() + if err := h.backend.SpliceText(h.ctx, h.handle, uint32(h.idx), 0, chars); err != nil { + h.err = err + + return + } + + h.idx += utf16Width(chars) + run.Reset() + } + + for i := 0; i < newLen && h.err == nil; i++ { + item := h.new[newIndex+i] + if !item.isBlock { + run.WriteString(item.grapheme) + + continue + } + + flush() + + if h.err != nil { + return + } + + blockHandle, err := h.backend.SplitBlock(h.ctx, h.handle, uint32(h.idx)) + if err != nil { + h.err = err + + return + } + + if err := h.backend.setBlockAttributes(h.ctx, blockHandle, item.block); err != nil { + h.err = err + + return + } + + h.idx++ + } + + flush() +} + +// desiredMarks flattens the marks named on text spans into absolute UTF-16 +// ranges in the order they appear. +func desiredMarks(spans []updateSpanInput) ([]desiredMark, error) { + marks := make([]desiredMark, 0) + index := uint32(0) + + for _, span := range spans { + if span.Type == "block" { + index++ + + continue + } + + width := uint32(utf16Width(span.Text)) + + names := make([]string, 0, len(span.Marks)) + for name := range span.Marks { + names = append(names, name) + } + + sort.Strings(names) + + for _, name := range names { + value, err := decodeScalarWire(span.Marks[name]) + if err != nil { + return nil, fmt.Errorf("cannot decode mark %q value: %w", name, err) + } + + marks = append(marks, desiredMark{ + name: name, + value: value, + start: index, + end: index + width, + }) + } + + index += width + } + + return marks, nil +} + +// reconcileMarks removes marks that are not desired and adds the ones that are +// missing, matching the two-phase reconciliation upstream performs. +func (b *Backend) reconcileMarks( + ctx context.Context, + handle uint32, + object ObjectID, + desired []desiredMark, + config updateSpansConfigInput, +) error { + for _, current := range b.state.Marks(object.OpID) { + keep := false + + for _, want := range desired { + if want.name == current.Name && + want.start == current.Start && + want.end == current.End && + current.Value != nil && + scalarValuesEqual(want.value, *current.Value) { + keep = true + + break + } + } + + if keep { + continue + } + + if err := b.markRange( + ctx, + handle, + current.Start, + current.End, + current.Name, + Scalar{Type: ScalarNull}, + config.expandFor(current.Name), + ); err != nil { + return err + } + } + + for _, want := range desired { + exists := false + + for _, current := range b.state.Marks(object.OpID) { + if want.name == current.Name && + want.start == current.Start && + want.end == current.End && + current.Value != nil && + scalarValuesEqual(want.value, *current.Value) { + exists = true + + break + } + } + + if exists { + continue + } + + if err := b.markRange( + ctx, + handle, + want.start, + want.end, + want.name, + want.value, + config.expandFor(want.name), + ); err != nil { + return err + } + } + + return nil +} + +// setBlockAttributes writes the attribute map onto a freshly created block +// object, recursing into nested maps and lists. +func (b *Backend) setBlockAttributes( + ctx context.Context, + handle uint32, + attributes map[string]any, +) error { + keys := make([]string, 0, len(attributes)) + for key := range attributes { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + if err := b.setMapValue(ctx, handle, key, attributes[key]); err != nil { + return err + } + } + + return nil +} + +func (b *Backend) setMapValue( + ctx context.Context, + handle uint32, + key string, + value any, +) error { + switch typed := value.(type) { + case map[string]any: + child, err := b.PutObject(ctx, handle, key, "map") + if err != nil { + return err + } + + return b.setBlockAttributes(ctx, child, typed) + case []any: + child, err := b.PutObject(ctx, handle, key, "list") + if err != nil { + return err + } + + for index, element := range typed { + if err := b.insertListValue(ctx, child, uint64(index), element); err != nil { + return err + } + } + + return nil + default: + scalar, err := hydrateScalar(value) + if err != nil { + return err + } + + encoded, err := encodeScalarWire(scalar) + if err != nil { + return err + } + + return b.PutScalar(ctx, handle, key, encoded) + } +} + +func (b *Backend) insertListValue( + ctx context.Context, + handle uint32, + index uint64, + value any, +) error { + switch typed := value.(type) { + case map[string]any: + child, err := b.InsertObject(ctx, handle, index, "map") + if err != nil { + return err + } + + return b.setBlockAttributes(ctx, child, typed) + case []any: + child, err := b.InsertObject(ctx, handle, index, "list") + if err != nil { + return err + } + + for offset, element := range typed { + if err := b.insertListValue(ctx, child, uint64(offset), element); err != nil { + return err + } + } + + return nil + default: + scalar, err := hydrateScalar(value) + if err != nil { + return err + } + + encoded, err := encodeScalarWire(scalar) + if err != nil { + return err + } + + return b.InsertScalar(ctx, handle, index, encoded) + } +} + +// hydrateScalar maps a decoded JSON scalar to an Automerge scalar, treating +// integral numbers as integers to match the reference block hydration. +func hydrateScalar(value any) (Scalar, error) { + switch typed := value.(type) { + case nil: + return Scalar{Type: ScalarNull}, nil + case bool: + if typed { + return Scalar{Type: ScalarTrue, Bool: true}, nil + } + + return Scalar{Type: ScalarFalse}, nil + case string: + return Scalar{Type: ScalarString, String: typed}, nil + case float64: + if typed == float64(int64(typed)) { + return Scalar{Type: ScalarInt, Int: int64(typed)}, nil + } + + return Scalar{Type: ScalarFloat64, Float: typed}, nil + default: + return Scalar{}, fmt.Errorf("unsupported block attribute value %T", value) + } +} + +func (b *Backend) markRange( + ctx context.Context, + handle uint32, + start uint32, + end uint32, + name string, + value Scalar, + expand string, +) error { + encoded, err := encodeScalarWire(value) + if err != nil { + return err + } + + return b.MarkText(ctx, handle, start, end, name, encoded, expand) +} + +func (c updateSpansConfigInput) expandFor(name string) string { + if expand, ok := c.PerMarkExpands[name]; ok && expand != "" { + return expand + } + + if c.DefaultExpand != "" { + return c.DefaultExpand + } + + return "after" +} + +func (b *Backend) MarkText( + ctx context.Context, + handle uint32, + start uint32, + end uint32, + name string, + encoded []byte, + expand string, +) error { + if err := ctx.Err(); err != nil { + return err + } + + if start > end { + return fmt.Errorf("mark range is inverted") + } + + if start == end && expand == "none" { + return nil + } + + object, err := b.textObject(handle) + if err != nil { + return err + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return err + } + + startKey, err := b.textMarkKey(object, start) + if err != nil { + return err + } + + endKey, err := b.textMarkKey(object, end) + if err != nil { + return err + } + + expandBefore, expandAfter, err := markExpansion(expand) + if err != nil { + return err + } + + begin := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: startKey, + Insert: true, + Action: ActionMark, + Value: &value, + MarkExpand: &expandBefore, + MarkName: &name, + } + if err := b.addPending(begin); err != nil { + return err + } + + endOperation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: endKey, + Insert: true, + Action: ActionMark, + Value: &Scalar{Type: ScalarNull}, + MarkExpand: &expandAfter, + } + + return b.addPending(endOperation) +} + +func (b *Backend) SplitBlock( + ctx context.Context, + handle uint32, + index uint32, +) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + object, err := b.textObject(handle) + if err != nil { + return 0, err + } + + sequence := b.state.sequenceElements(object.OpID) + + _, previous, err := richTextPosition(sequence, index) + if err != nil { + return 0, err + } + + key := Key{IsHead: previous == nil} + if previous != nil { + key.Element = new(*previous) + } + + operation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: key, + Insert: true, + Action: ActionMakeMap, + } + if err := b.addPending(operation); err != nil { + return 0, err + } + + return b.pushObject(ObjectID{OpID: operation.ID}), nil +} + +func (b *Backend) JoinBlock( + ctx context.Context, + handle uint32, + index uint32, +) error { + if err := ctx.Err(); err != nil { + return err + } + + object, err := b.textObject(handle) + if err != nil { + return err + } + + sequence := b.state.sequenceElements(object.OpID) + + target, _, err := richTextPosition(sequence, index) + if err != nil { + return err + } + + if target == nil || target.Action != ActionMakeMap { + return fmt.Errorf("text position %d is not a block", index) + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: object, + Key: Key{Element: new(target.ID)}, + Action: ActionDelete, + Predecessors: []OpID{target.ID}, + }) +} + +func (b *Backend) ReplaceBlock( + ctx context.Context, + handle uint32, + index uint32, +) (uint32, error) { + if err := b.JoinBlock(ctx, handle, index); err != nil { + return 0, err + } + + return b.SplitBlock(ctx, handle, index) +} + +func (b *Backend) Text(ctx context.Context, handle uint32) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + object, err := b.textObject(handle) + if err != nil { + return "", err + } + + var output strings.Builder + + // Materialize the winning value of every visible element so that a put over + // a text position replaces the original character, matching the reference. + for _, value := range b.state.sequenceValues(object.OpID) { + operation := value.Operation + if operation.Value != nil && operation.Value.Type == ScalarString { + output.WriteString(operation.Value.String) + } + } + + return output.String(), nil +} + +func (b *Backend) TextAt( + ctx context.Context, + handle uint32, + heads [][32]byte, +) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + + object, err := b.textObject(handle) + if err != nil { + return "", err + } + + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return "", fmt.Errorf("historical heads are unknown") + } + + var output strings.Builder + + for _, operation := range historical.sequence(object.OpID) { + if operation.Value != nil && operation.Value.Type == ScalarString { + output.WriteString(operation.Value.String) + } + } + + return output.String(), nil +} + +func (b *Backend) TextSpans( + ctx context.Context, + handle uint32, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + spans, err := b.state.RichTextSpans(object.OpID) + if err != nil { + return nil, err + } + + data, err := json.Marshal(spans) + if err != nil { + return nil, fmt.Errorf("cannot encode native rich-text spans: %w", err) + } + + return data, nil +} + +func (b *Backend) TextSpansAt( + ctx context.Context, + handle uint32, + heads [][32]byte, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return nil, fmt.Errorf("historical heads are unknown") + } + + spans, err := historical.RichTextSpans(object.OpID) + if err != nil { + return nil, err + } + + data, err := json.Marshal(spans) + if err != nil { + return nil, fmt.Errorf("cannot encode native historical rich-text spans: %w", err) + } + + return data, nil +} + +func encodeMarks(marks []MarkRange) ([]byte, error) { + type markWire struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` + Name string `json:"name"` + Value json.RawMessage `json:"value"` + } + + wire := make([]markWire, 0, len(marks)) + + for _, mark := range marks { + value := &Scalar{Type: ScalarNull} + if mark.Value != nil { + value = mark.Value + } + + encoded, err := encodeScalarWire(*value) + if err != nil { + return nil, err + } + + wire = append(wire, markWire{ + Start: mark.Start, + End: mark.End, + Name: mark.Name, + Value: json.RawMessage(encoded), + }) + } + + data, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("cannot encode native marks: %w", err) + } + + return data, nil +} + +func (b *Backend) Marks(ctx context.Context, handle uint32) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + return encodeMarks(b.state.Marks(object.OpID)) +} + +func (b *Backend) MarksAt( + ctx context.Context, + handle uint32, + heads [][32]byte, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return nil, fmt.Errorf("historical heads are unknown") + } + + return encodeMarks(historical.Marks(object.OpID)) +} + +func (b *Backend) TextCursor( + ctx context.Context, + handle uint32, + index uint32, +) ([]byte, error) { + return b.TextCursorMoving(ctx, handle, index, false) +} + +func (b *Backend) TextCursorMoving( + ctx context.Context, + handle uint32, + index uint32, + moveBefore bool, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + sequence := b.state.sequence(object.OpID) + + position := uint32(0) + + for _, operation := range sequence { + length := uint32(utf16Length(operation)) + if index >= position && index < position+length { + data := []byte{1, 3} + data = appendLengthPrefixedNative(data, operation.ID.Actor.Bytes()) + + data = appendULEB(data, operation.ID.Counter) + if moveBefore { + data = append(data, 1) + } else { + data = append(data, 2) + } + + return data, nil + } + + position += length + } + + return nil, fmt.Errorf("text cursor index %d is out of bounds", index) +} + +func (b *Backend) TextCursorMovingAt( + ctx context.Context, + handle uint32, + index uint32, + moveBefore bool, + heads [][32]byte, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + object, err := b.textObject(handle) + if err != nil { + return nil, err + } + + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return nil, fmt.Errorf("historical heads are unknown") + } + + position := uint32(0) + + for _, operation := range historical.sequence(object.OpID) { + length := uint32(utf16Length(operation)) + if index >= position && index < position+length { + data := []byte{1, 3} data = appendLengthPrefixedNative(data, operation.ID.Actor.Bytes()) data = appendULEB(data, operation.ID.Counter) - data = append(data, 2) - return data, nil + if moveBefore { + data = append(data, 1) + } else { + data = append(data, 2) + } + + return data, nil + } + + position += length + } + + return nil, fmt.Errorf("text cursor index %d is out of bounds", index) +} + +func (b *Backend) TextCursorPosition( + ctx context.Context, + handle uint32, + cursor []byte, +) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + object, err := b.textObject(handle) + if err != nil { + return 0, err + } + + if bytes.Equal(cursor, []byte{1, 1}) { + return 0, nil + } + + if bytes.Equal(cursor, []byte{1, 2}) { + var length uint32 + for _, operation := range b.state.sequence(object.OpID) { + length += uint32(utf16Length(operation)) + } + + return length, nil + } + + target, move, err := decodeCursor(cursor) + if err != nil { + return 0, err + } + + position := uint32(0) + + for _, operation := range b.state.sequenceAll(object.OpID) { + if operation.ID == target { + if b.state.isSuperseded(operation.ID) && move == 1 { + return b.cursorMoveBeforePosition(object.OpID, operation) + } + + return position, nil + } + + if !b.state.isSuperseded(operation.ID) { + position += uint32(utf16Length(operation)) + } + } + + return 0, fmt.Errorf("text cursor target does not exist") +} + +func (b *Backend) cursorMoveBeforePosition( + object OpID, + target Operation, +) (uint32, error) { + visited := make(map[OpID]struct{}) + + for { + if target.Key.IsHead { + return 0, nil + } + + if target.Key.Element == nil { + return 0, fmt.Errorf("text cursor target has no predecessor") + } + + if _, ok := visited[*target.Key.Element]; ok { + return 0, fmt.Errorf("text cursor predecessor cycle") + } + + visited[*target.Key.Element] = struct{}{} + + var position uint32 + + for _, operation := range b.state.sequence(object) { + if operation.ID == *target.Key.Element { + return position, nil + } + + position += uint32(utf16Length(operation)) + } + + predecessor, ok := b.state.operations[*target.Key.Element] + if !ok { + return 0, fmt.Errorf("text cursor predecessor does not exist") + } + + target = predecessor + } +} + +// changeDependencies computes the dependency set for a new change authored by +// this backend's actor at the given sequence number. The dependencies are the +// current heads plus, matching upstream Rust, the actor's own previous change +// hash when it is not already a head (so that direct causal succession from the +// author's prior change is always recorded explicitly). +func (b *Backend) changeDependencies(sequence uint64) []ChangeHash { + dependencies := b.state.Heads() + + if sequence > 1 { + last, ok := b.state.hashForActorSequence(b.actor, sequence-1) + if ok && !containsHash(dependencies, last) { + dependencies = append(dependencies, last) + sort.Slice(dependencies, func(i, j int) bool { + return bytes.Compare(dependencies[i][:], dependencies[j][:]) < 0 + }) + } + } + + return dependencies +} + +func containsHash(hashes []ChangeHash, target ChangeHash) bool { + for _, hash := range hashes { + if hash == target { + return true + } + } + + return false +} + +// Isolate pins the document to the given heads: subsequent reads reflect that +// frontier plus isolated writes, and new changes branch from it using a derived +// isolation actor so they never collide with the base actor's later history. It +// mirrors Rust's AutoCommit::isolate. Repeated calls re-pin to fresh heads. +func (b *Backend) Isolate(ctx context.Context, heads [][32]byte) error { + if err := ctx.Err(); err != nil { + return err + } + + if len(b.pending) > 0 { + if _, err := b.Commit(ctx, "", time.Time{}); err != nil { + return err + } + } + + full := b.fullState + if !b.isolationActive { + full = b.state + } + + nativeHeads := nativeHashes(heads) + + pinned, ok := full.at(nativeHeads) + if !ok { + return fmt.Errorf("isolation heads are unknown") + } + + baseActor := b.baseActor + if !b.isolationActive { + baseActor = b.actor + } + + b.isolationActive = true + b.fullState = full + b.baseActor = baseActor + b.state = pinned + b.actor = isolationActor(full, pinned, baseActor) + b.nextOp = full.maxOpGlobal() + 1 + + b.isolationDiffTargets = append( + b.isolationDiffTargets, + append([][32]byte(nil), nativeToArrayHeads(nativeHeads)...), + ) + + return nil +} + +// nativeToArrayHeads converts change hashes to the [32]byte head form used by +// the incremental diff cursor. +func nativeToArrayHeads(heads []ChangeHash) [][32]byte { + result := make([][32]byte, len(heads)) + for i, hash := range heads { + result[i] = [32]byte(hash) + } + + return result +} + +// Integrate ends isolation, returning reads and writes to the full history that +// accumulated every isolated and merged change. It mirrors AutoCommit::integrate. +func (b *Backend) Integrate(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if !b.isolationActive { + return nil + } + + if len(b.pending) > 0 { + if _, err := b.Commit(ctx, "", time.Time{}); err != nil { + return err + } + } + + b.state = b.fullState + b.actor = b.baseActor + b.fullState = nil + b.isolationActive = false + b.nextOp = b.state.maxOpGlobal() + 1 + + return nil +} + +// isolationActor selects the actor for isolated writes: the base actor when all +// of its operations are already covered by the isolation heads, otherwise the +// lowest-level derived concurrency actor whose operations are covered, matching +// Rust's isolate_actor. +func isolationActor(full, pinned *State, base ActorID) ActorID { + for level := uint64(0); ; level++ { + candidate := base.WithConcurrency(level) + if full.maxOpForActor(candidate) == pinned.maxOpForActor(candidate) { + return candidate + } + } +} + +func (b *Backend) Commit( + ctx context.Context, + message string, + timestamp time.Time, +) ([32]byte, error) { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + + if len(b.pending) == 0 { + return [32]byte{}, fmt.Errorf("change contains no operations") + } + + sequence := b.state.sequenceForActor(b.actor) + 1 + dependencies := b.changeDependencies(sequence) + + change := &Change{ + Actor: b.actor, + Sequence: sequence, + StartOp: b.pending[0].ID.Counter, + MaxOp: b.pending[len(b.pending)-1].ID.Counter, + Time: timestamp.Unix(), + Message: message, + Dependencies: dependencies, + Operations: append([]Operation(nil), b.pending...), + } + if timestamp.IsZero() { + change.Time = 0 + } + + raw, err := EncodeChange(change) + if err != nil { + return [32]byte{}, fmt.Errorf("cannot encode native change: %w", err) + } + + if err := b.state.recordAppliedChange(change); err != nil { + return [32]byte{}, err + } + + // While isolated, the pinned view holds the change for subsequent reads, but + // the full history must also record it so integration sees every isolated + // change alongside merges. Decode a fresh copy from the encoded bytes so the + // two states never share mutable operation state. + if b.isolationActive && b.fullState != nil { + document, err := DecodePartial(raw) + if err != nil || len(document.Changes) == 0 { + return [32]byte{}, fmt.Errorf("cannot decode isolated change for full history: %w", err) + } + + fullChange := document.Changes[0] + + fullChange.Raw = append([]byte(nil), raw...) + + if err := b.fullState.ApplyChange(&fullChange); err != nil { + return [32]byte{}, err + } + + if next := b.fullState.maxOpGlobal() + 1; next > b.nextOp { + b.nextOp = next + } + } + + b.appended = append(b.appended, raw) + b.pending = nil + + return [32]byte(*change.Hash), nil +} + +func (b *Backend) EmptyCommit( + ctx context.Context, + message string, + timestamp time.Time, +) ([32]byte, error) { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + + if len(b.pending) != 0 { + return [32]byte{}, fmt.Errorf("cannot create empty change with pending operations") + } + + sequence := b.state.sequenceForActor(b.actor) + 1 + + change := &Change{ + Actor: b.actor, + Sequence: sequence, + StartOp: b.nextOp, + MaxOp: b.nextOp - 1, + Time: timestamp.Unix(), + Message: message, + Dependencies: b.changeDependencies(sequence), + } + if timestamp.IsZero() { + change.Time = 0 + } + + raw, err := EncodeChange(change) + if err != nil { + return [32]byte{}, fmt.Errorf("cannot encode native empty change: %w", err) + } + + if err := b.state.recordAppliedChange(change); err != nil { + return [32]byte{}, err + } + + b.appended = append(b.appended, raw) + + return [32]byte(*change.Hash), nil +} + +func (b *Backend) Rollback(ctx context.Context) (uint64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + if len(b.pending) == 0 { + return 0, nil + } + + data := append([]byte(nil), b.base...) + for _, change := range b.appended { + data = append(data, change...) + } + + document, err := Decode(data) + if err != nil { + return 0, fmt.Errorf("cannot decode committed state during rollback: %w", err) + } + + state, err := NewStateFromDocument(document) + if err != nil { + return 0, fmt.Errorf("cannot restore committed state during rollback: %w", err) + } + + cancelled := uint64(len(b.pending)) + b.state = state + b.nextOp = state.maxOpGlobal() + 1 + b.pending = nil + b.objects = map[uint32]ObjectID{0: RootObject()} + b.nextHandle = 1 + + return cancelled, nil +} + +func (b *Backend) Stats(ctx context.Context) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + actors := make(map[ActorID]struct{}) + for id := range b.state.operations { + actors[id.Actor] = struct{}{} + } + + stats := struct { + NumChanges uint64 `json:"numChanges"` + NumOps uint64 `json:"numOps"` + NumActors uint64 `json:"numActors"` + }{ + NumChanges: uint64(len(b.state.changes)), + NumOps: uint64(len(b.state.operations)), + NumActors: uint64(len(actors)), + } + + data, err := json.Marshal(stats) + if err != nil { + return nil, fmt.Errorf("cannot encode native stats: %w", err) + } + + return data, nil +} + +type ( + patchOut struct { + Obj string `json:"obj"` + Action patchActionOut `json:"action"` + } + + patchActionOut struct { + Type string `json:"type"` + Key string `json:"key,omitempty"` + Index uint64 `json:"index"` + Length uint64 `json:"length,omitempty"` + Value *patchValueOut `json:"value,omitempty"` + Values []patchInsertOut `json:"values,omitempty"` + Text string `json:"text,omitempty"` + Conflict bool `json:"conflict"` + Marks []markPatchOut `json:"marks,omitempty"` + } + + markPatchOut struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` + Name string `json:"name"` + Value json.RawMessage `json:"value"` + } + + patchInsertOut struct { + Value patchValueOut `json:"value"` + Conflict bool `json:"conflict"` + } + + patchValueOut struct { + Scalar json.RawMessage `json:"scalar,omitempty"` + Object string `json:"object,omitempty"` + ID string `json:"id,omitempty"` + } +) + +func objectIDString(object ObjectID) string { + if object.IsRoot { + return "_root" + } + + return fmt.Sprintf( + "%d@%s", + object.OpID.Counter, + hex.EncodeToString([]byte(object.OpID.Actor)), + ) +} + +func patchValueForOperation(state *State, operation Operation) (patchValueOut, error) { + if objectType, err := actionObjectType(operation.Action); err == nil { + return patchValueOut{ + Object: objectType, + ID: objectIDString(ObjectID{OpID: operation.ID}), + }, nil + } + + value, ok := state.scalarValue(operation) + if !ok { + return patchValueOut{}, fmt.Errorf("operation %v has no materializable value", operation.ID) + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return patchValueOut{}, err + } + + return patchValueOut{Scalar: json.RawMessage(encoded)}, nil +} + +// CurrentState returns the patches that materialize the document from empty, +// ordered to match upstream Rust: the root first, then other objects by +// creation operation ID, with map keys in lexical order and sequence elements +// in index order. +func (b *Backend) CurrentState(ctx context.Context) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + patches := make([]patchOut, 0) + + for _, object := range orderedObjectsInState(b.state) { + objectPatches, err := materializeObjectPatches(b.state, object) + if err != nil { + return nil, err + } + + patches = append(patches, objectPatches...) + } + + data, err := json.Marshal(patches) + if err != nil { + return nil, fmt.Errorf("cannot encode native patches: %w", err) + } + + return data, nil +} + +// orderedObjectsInState returns the visible objects in a state, the root first +// and then every non-deleted composite object ordered by its creation ID. +func orderedObjectsInState(state *State) []ObjectID { + objects := []ObjectID{RootObject()} + + makers := make([]Operation, 0) + + for _, operation := range state.operations { + if _, err := actionObjectType(operation.Action); err != nil { + continue + } + + if state.isSuperseded(operation.ID) { + continue + } + + // A composite object concurrently assigned to the same map key as another + // object is a conflict alternative; only the winning value is materialized + // (its content spliced), matching the reference. Losing alternatives still + // exist but are surfaced only through the put's conflict flag. + if !operation.Insert && operation.Key.Property != nil { + if winner, ok := state.visibleMapObjectValue(operation.Object, *operation.Key.Property); ok && + winner.ID != operation.ID { + continue + } + } + + makers = append(makers, operation) + } + + sort.Slice(makers, func(i, j int) bool { + return makers[i].ID.Compare(makers[j].ID) < 0 + }) + + for _, operation := range makers { + objects = append(objects, ObjectID{OpID: operation.ID}) + } + + return objects +} + +func objectTypeInState(state *State, object ObjectID) (string, error) { + if object.IsRoot { + return "map", nil + } + + operation, ok := state.operations[object.OpID] + if !ok { + return "", fmt.Errorf("object %v does not exist", object.OpID) + } + + return actionObjectType(operation.Action) +} + +func objectVisibleInState(state *State, object ObjectID) bool { + if object.IsRoot { + return true + } + + if _, ok := state.operations[object.OpID]; !ok { + return false + } + + return !state.isSuperseded(object.OpID) +} + +// materializeObjectPatches emits the patches that build an object from empty. +func materializeObjectPatches(state *State, object ObjectID) ([]patchOut, error) { + objectType, err := objectTypeInState(state, object) + if err != nil { + return nil, err + } + + identifier := objectIDString(object) + + switch objectType { + case "map", "table": + patches := make([]patchOut, 0) + + for _, key := range state.mapKeys(object) { + winner, ok := state.visibleMapObjectValue(object, key) + if !ok { + continue + } + + value, err := patchValueForOperation(state, winner) + if err != nil { + return nil, err + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "put_map", + Key: key, + Value: &value, + Conflict: len(state.visibleMapObjectOperations(object, key)) > 1, + }, + }) + } + + return patches, nil + case "list": + values := state.sequenceValues(object.OpID) + if len(values) == 0 { + return nil, nil + } + + inserts := make([]patchInsertOut, 0, len(values)) + + for index := range values { + value, err := patchValueForOperation(state, values[index].Operation) + if err != nil { + return nil, err + } + + inserts = append(inserts, patchInsertOut{ + Value: value, + Conflict: len(state.visibleSequenceElementOperations(values[index].Element)) > 1, + }) + } + + return []patchOut{{ + Obj: identifier, + Action: patchActionOut{Type: "insert", Index: 0, Values: inserts}, + }}, nil + case "text": + patches := make([]patchOut, 0) + position := uint64(0) + + var run strings.Builder + + runStart := uint64(0) + + flush := func() error { + if run.Len() == 0 { + return nil + } + + runs, err := textRunsWithMarks(state, object, runStart, run.String()) + if err != nil { + return err + } + + for _, textRun := range runs { + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "splice_text", + Index: textRun.index, + Text: textRun.text, + Marks: textRun.marks, + }, + }) + } + + run.Reset() + + return nil + } + + for _, value := range state.sequenceValues(object.OpID) { + operation := value.Operation + + if operation.Action == ActionMakeMap { + if err := flush(); err != nil { + return nil, err + } + + blockValue, err := patchValueForOperation(state, operation) + if err != nil { + return nil, err + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "insert", + Index: position, + Values: []patchInsertOut{{Value: blockValue}}, + }, + }) + position++ + + continue + } + + if operation.Value != nil && operation.Value.Type == ScalarString { + if run.Len() == 0 { + runStart = position + } + + run.WriteString(operation.Value.String) + position += uint64(utf16Width(operation.Value.String)) + } + } + + if err := flush(); err != nil { + return nil, err + } + + return patches, nil + default: + return nil, fmt.Errorf("unknown object type %q", objectType) + } +} + +// Diff returns the patches that transform the document state at the before heads +// into the state at the after heads. +func (b *Backend) Diff( + ctx context.Context, + beforeHeads [][32]byte, + afterHeads [][32]byte, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + patches, err := b.diffPatches(beforeHeads, afterHeads, false) + if err != nil { + return nil, err + } + + data, err := json.Marshal(patches) + if err != nil { + return nil, fmt.Errorf("cannot encode native diff patches: %w", err) + } + + return data, nil +} + +// UpdateDiffCursor records the current heads as the incremental diff cursor so a +// following DiffIncremental reports only the changes committed since this call. +func (b *Backend) UpdateDiffCursor(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + heads, err := b.Heads(ctx) + if err != nil { + return err + } + + b.diffCursor = heads + b.isolationDiffTargets = nil + + return nil +} + +// DiffIncremental returns the patches for the changes committed since the diff +// cursor (or from an empty document when the cursor is unset) and advances the +// cursor to the current heads, mirroring the reference diff_incremental helper. +func (b *Backend) DiffIncremental(ctx context.Context) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + heads, err := b.Heads(ctx) + if err != nil { + return nil, err + } + + patches, err := b.incrementalDiffPatches(heads) + if err != nil { + return nil, err + } + + b.diffCursor = heads + b.isolationDiffTargets = nil + + data, err := json.Marshal(patches) + if err != nil { + return nil, fmt.Errorf("cannot encode native incremental diff patches: %w", err) + } + + return data, nil +} + +// incrementalDiffPatches computes the incremental patches from the diff cursor +// to the current heads. When isolation frontiers were recorded in the window, +// the diff is chained through each of them (cursor to each isolation frontier +// and finally to the current heads) so the patch stream matches the reference's +// patch-log output across isolate/integrate. +func (b *Backend) incrementalDiffPatches(heads [][32]byte) ([]patchOut, error) { + if len(b.isolationDiffTargets) == 0 { + return b.incrementalPatches(b.diffCursor, heads) + } + + frontiers := make([][][32]byte, 0, len(b.isolationDiffTargets)+2) + frontiers = append(frontiers, b.diffCursor) + frontiers = append(frontiers, b.isolationDiffTargets...) + frontiers = append(frontiers, heads) + + patches := make([]patchOut, 0) + + for i := 0; i+1 < len(frontiers); i++ { + segment, err := b.incrementalPatches(frontiers[i], frontiers[i+1]) + if err != nil { + return nil, err + } + + patches = append(patches, segment...) + } + + return patches, nil +} + +func (b *Backend) incrementalPatches( + beforeHeads [][32]byte, + afterHeads [][32]byte, +) ([]patchOut, error) { + return b.diffPatches(beforeHeads, afterHeads, true) +} + +func (b *Backend) diffPatches( + beforeHeads [][32]byte, + afterHeads [][32]byte, + incremental bool, +) ([]patchOut, error) { + source, ok := b.state.at(nativeHashes(beforeHeads)) + if !ok { + return nil, fmt.Errorf("before heads are unknown") + } + + target, ok := b.state.at(nativeHashes(afterHeads)) + if !ok { + return nil, fmt.Errorf("after heads are unknown") + } + + patches := make([]patchOut, 0) + + for _, object := range orderedObjectsInState(target) { + var ( + objectPatches []patchOut + err error + ) + + if objectVisibleInState(source, object) { + objectPatches, err = diffObjectPatches(source, target, object, incremental) + } else { + objectPatches, err = materializeObjectPatches(target, object) + } + + if err != nil { + return nil, err + } + + patches = append(patches, objectPatches...) + } + + return patches, nil +} + +// diffObjectPatches emits patches transforming an object from the source state +// into the target state, for an object present in both. +func diffObjectPatches(source, target *State, object ObjectID, incremental bool) ([]patchOut, error) { + objectType, err := objectTypeInState(target, object) + if err != nil { + return nil, err + } + + identifier := objectIDString(object) + + switch objectType { + case "map", "table": + return diffMapPatches(source, target, object, identifier) + case "list": + return diffSequencePatches(source, target, object, objectType, identifier, incremental) + case "text": + patches, err := diffSequencePatches(source, target, object, objectType, identifier, incremental) + if err != nil { + return nil, err + } + + return mergeTextMarkPatches(source, target, object, identifier, patches) + default: + return nil, fmt.Errorf("unknown object type %q", objectType) + } +} + +// mergeTextMarkPatches folds the mark differences between the source and target +// states into the ordered sequence patches for a text object. Added or changed +// marks carry their new value; removed marks carry a null value. The reference +// emits a single mark patch positioned by the smallest affected index, so the +// combined patch is inserted before the first sequence patch beyond that index. +func mergeTextMarkPatches( + source, target *State, + object ObjectID, + identifier string, + patches []patchOut, +) ([]patchOut, error) { + // The reference derives mark patches from the mark operations applied in the + // window, not from state comparison, so a mark range that merely grew because + // text was spliced into an expanding mark produces no mark patch (the marks + // ride on the splice patch instead), and a partial unmark reports the literal + // operation range rather than the resulting split. + marks, err := diffMarkPatches(source, target, object) + if err != nil { + return nil, err + } + + if len(marks) == 0 { + return patches, nil + } + + anchor := marks[0].Start + for _, mark := range marks[1:] { + if mark.Start < anchor { + anchor = mark.Start + } + } + + markPatch := patchOut{ + Obj: identifier, + Action: patchActionOut{Type: "mark", Marks: marks}, + } + + insertAt := len(patches) + + for index, patch := range patches { + if patch.Action.Index > uint64(anchor) { + insertAt = index + + break + } + } + + merged := make([]patchOut, 0, len(patches)+1) + merged = append(merged, patches[:insertAt]...) + merged = append(merged, markPatch) + merged = append(merged, patches[insertAt:]...) + + return merged, nil +} + +// diffMarkPatches reports the mark and unmark operations applied in the window +// between the source and target states as Mark patch entries, ordered by the +// operation identifier so they appear in application order. Each entry carries +// the operation's literal UTF-16 range and value (null for an unmark), matching +// the reference's operation-based diff rather than a state comparison. +func diffMarkPatches(source, target *State, object ObjectID) ([]markPatchOut, error) { + begins := make([]Operation, 0) + + for id, operation := range target.operations { + if operation.Action != ActionMark || + operation.Object != object || + operation.MarkName == nil { + continue } - position += uint32(utf16Length(operation)) + if _, ok := source.operations[id]; ok { + continue + } + + begins = append(begins, operation) } - return nil, fmt.Errorf("text cursor index %d is out of bounds", index) -} + sort.Slice(begins, func(i, j int) bool { + return begins[i].ID.Compare(begins[j].ID) < 0 + }) -func (b *Backend) TextCursorPosition( - ctx context.Context, - handle uint32, - cursor []byte, -) (uint32, error) { - if err := ctx.Err(); err != nil { - return 0, err - } + out := make([]markPatchOut, 0, len(begins)) - object, err := b.object(handle) - if err != nil { - return 0, err + for _, begin := range begins { + end, ok := target.operations[OpID{Actor: begin.ID.Actor, Counter: begin.ID.Counter + 1}] + if !ok { + continue + } + + start, finish, ok := target.markOpUTF16Range(object.OpID, begin, end) + if !ok { + continue + } + + value := Scalar{Type: ScalarNull} + if begin.Value != nil { + value = *begin.Value + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return nil, err + } + + out = append(out, markPatchOut{ + Start: start, + End: finish, + Name: *begin.MarkName, + Value: json.RawMessage(encoded), + }) } - target, _, err := decodeCursor(cursor) - if err != nil { - return 0, err + return out, nil +} + +type textRun struct { + index uint64 + text string + marks []markPatchOut +} + +// textRunsWithMarks splits a run of text starting at the given UTF-16 position +// into maximal sub-runs that share the same active mark set, attaching those +// marks (sorted by name) to each sub-run. It mirrors the reference, which emits +// one splice_text patch per mark run. +func textRunsWithMarks( + state *State, + object ObjectID, + startPosition uint64, + text string, +) ([]textRun, error) { + ranges := state.Marks(object.OpID) + + activeMarks := func(position uint64) ([]markPatchOut, string, error) { + marks := make([]markPatchOut, 0) + + for _, candidate := range ranges { + if uint64(candidate.Start) > position || position >= uint64(candidate.End) { + continue + } + + value := Scalar{Type: ScalarNull} + if candidate.Value != nil { + value = *candidate.Value + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return nil, "", err + } + + marks = append(marks, markPatchOut{ + Name: candidate.Name, + Value: json.RawMessage(encoded), + }) + } + + sort.Slice(marks, func(i, j int) bool { + return marks[i].Name < marks[j].Name + }) + + var key strings.Builder + for _, mark := range marks { + key.WriteString(mark.Name) + key.WriteByte('=') + key.Write(mark.Value) + key.WriteByte(';') + } + + if len(marks) == 0 { + marks = nil + } + + return marks, key.String(), nil } - position := uint32(0) + runs := make([]textRun, 0) - for _, operation := range b.state.sequenceAll(object.OpID) { - if operation.ID == target { - return position, nil + var ( + builder strings.Builder + runMarks []markPatchOut + runKey string + runStart = startPosition + position = startPosition + haveRun bool + ) + + for _, character := range text { + marks, key, err := activeMarks(position) + if err != nil { + return nil, err } - if !b.state.isSuperseded(operation.ID) { - position += uint32(utf16Length(operation)) + if !haveRun { + runStart = position + runMarks = marks + runKey = key + haveRun = true + } else if key != runKey { + runs = append(runs, textRun{index: runStart, text: builder.String(), marks: runMarks}) + builder.Reset() + runStart = position + runMarks = marks + runKey = key + } + + builder.WriteRune(character) + + if character > 0xFFFF { + position += 2 + } else { + position++ } } - return 0, fmt.Errorf("text cursor target does not exist") + if haveRun { + runs = append(runs, textRun{index: runStart, text: builder.String(), marks: runMarks}) + } + + return runs, nil } -func (b *Backend) Commit( - ctx context.Context, - message string, - timestamp time.Time, -) ([32]byte, error) { - if err := ctx.Err(); err != nil { - return [32]byte{}, err +func diffMapPatches( + source, target *State, + object ObjectID, + identifier string, +) ([]patchOut, error) { + keys := make(map[string]struct{}) + for _, key := range source.mapKeys(object) { + keys[key] = struct{}{} } - if len(b.pending) == 0 { - return [32]byte{}, fmt.Errorf("change contains no operations") + for _, key := range target.mapKeys(object) { + keys[key] = struct{}{} } - dependencies := b.state.Heads() + ordered := make([]string, 0, len(keys)) + for key := range keys { + ordered = append(ordered, key) + } - change := &Change{ - Actor: b.actor, - Sequence: b.state.sequenceForActor(b.actor) + 1, - StartOp: b.pending[0].ID.Counter, - MaxOp: b.pending[len(b.pending)-1].ID.Counter, - Time: timestamp.Unix(), - Message: message, - Dependencies: dependencies, - Operations: append([]Operation(nil), b.pending...), + sort.Strings(ordered) + + patches := make([]patchOut, 0) + + for _, key := range ordered { + targetOp, targetOK := target.visibleMapObjectValue(object, key) + sourceOp, sourceOK := source.visibleMapObjectValue(object, key) + + switch { + case targetOK && (!sourceOK || targetOp.ID != sourceOp.ID): + value, err := patchValueForOperation(target, targetOp) + if err != nil { + return nil, err + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "put_map", + Key: key, + Value: &value, + Conflict: len(target.visibleMapObjectOperations(object, key)) > 1, + }, + }) + case !targetOK && sourceOK: + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{Type: "delete_map", Key: key}, + }) + } } - if timestamp.IsZero() { - change.Time = 0 + + return patches, nil +} + +func diffSequencePatches( + source, target *State, + object ObjectID, + objectType string, + identifier string, + incremental bool, +) ([]patchOut, error) { + sourceValues := source.sequenceValues(object.OpID) + targetValues := target.sequenceValues(object.OpID) + + sourceElements := make(map[OpID]struct{}, len(sourceValues)) + for _, value := range sourceValues { + sourceElements[value.Element] = struct{}{} } - raw, err := EncodeChange(change) - if err != nil { - return [32]byte{}, fmt.Errorf("cannot encode native change: %w", err) + targetElements := make(map[OpID]struct{}, len(targetValues)) + for _, value := range targetValues { + targetElements[value.Element] = struct{}{} } - if err := b.state.recordAppliedChange(change); err != nil { - return [32]byte{}, err + // Text objects report positions in UTF-16 code units; other sequences use + // one unit per element. + width := func(value sequenceValue) uint64 { + if objectType == "text" { + return sequenceValueUTF16Width(value) + } + + return 1 } - b.appended = append(b.appended, raw) - b.pending = nil + patches := make([]patchOut, 0) + position := uint64(0) + i, j := 0, 0 - return [32]byte(*change.Hash), nil + for i < len(sourceValues) || j < len(targetValues) { + if i < len(sourceValues) && j < len(targetValues) && + sourceValues[i].Element == targetValues[j].Element { + if sourceValues[i].Operation.ID == targetValues[j].Operation.ID { + position += width(targetValues[j]) + i++ + j++ + + continue + } + + // Same element, different winning value. A state-comparison diff of + // text cannot express an in-place replacement, so it becomes a + // delete followed by a splice; the incremental patch log and every + // list report a put_seq instead, mirroring the reference. + if objectType == "text" && !incremental { + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "delete_seq", + Index: position, + Length: width(sourceValues[i]), + }, + }) + + operation := targetValues[j].Operation + if operation.Value != nil && operation.Value.Type == ScalarString { + runs, err := textRunsWithMarks(target, object, position, operation.Value.String) + if err != nil { + return nil, err + } + + for _, run := range runs { + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "splice_text", + Index: run.index, + Text: run.text, + Marks: run.marks, + }, + }) + } + + position += width(targetValues[j]) + } + } else { + value, err := patchValueForOperation(target, targetValues[j].Operation) + if err != nil { + return nil, err + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "put_seq", + Index: position, + Value: &value, + Conflict: len(target.visibleSequenceElementOperations(targetValues[j].Element)) > 1, + }, + }) + position += width(targetValues[j]) + } + + i++ + j++ + + continue + } + + if i < len(sourceValues) { + if _, ok := targetElements[sourceValues[i].Element]; !ok { + length := uint64(0) + + for i < len(sourceValues) { + if _, ok := targetElements[sourceValues[i].Element]; ok { + break + } + + length += width(sourceValues[i]) + i++ + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "delete_seq", + Index: position, + Length: length, + }, + }) + + continue + } + } + + if objectType == "text" { + var text strings.Builder + + start := position + + for j < len(targetValues) { + if _, ok := sourceElements[targetValues[j].Element]; ok { + break + } + + operation := targetValues[j].Operation + if operation.Value == nil || operation.Value.Type != ScalarString { + break + } + + text.WriteString(operation.Value.String) + position += width(targetValues[j]) + j++ + } + + if text.Len() > 0 { + runs, err := textRunsWithMarks(target, object, start, text.String()) + if err != nil { + return nil, err + } + + for _, run := range runs { + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "splice_text", + Index: run.index, + Text: run.text, + Marks: run.marks, + }, + }) + } + + continue + } + } + + inserts := make([]patchInsertOut, 0) + start := position + + for j < len(targetValues) { + if _, ok := sourceElements[targetValues[j].Element]; ok { + break + } + + value, err := patchValueForOperation(target, targetValues[j].Operation) + if err != nil { + return nil, err + } + + inserts = append(inserts, patchInsertOut{ + Value: value, + Conflict: len(target.visibleSequenceElementOperations(targetValues[j].Element)) > 1, + }) + position++ + j++ + } + + if len(inserts) == 0 { + break + } + + patches = append(patches, patchOut{ + Obj: identifier, + Action: patchActionOut{ + Type: "insert", + Index: start, + Values: inserts, + }, + }) + } + + return patches, nil } func (b *Backend) Heads(ctx context.Context) ([][32]byte, error) { @@ -476,6 +3590,60 @@ func (b *Backend) Heads(ctx context.Context) ([][32]byte, error) { return result, nil } +func (b *Backend) HasHeads( + ctx context.Context, + heads [][32]byte, +) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + + for _, head := range heads { + if !b.state.hasChange(ChangeHash(head)) { + return false, nil + } + } + + return true, nil +} + +func (b *Backend) MissingDependencies( + ctx context.Context, + heads [][32]byte, +) ([][32]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + missing := make(map[[32]byte]struct{}) + + for _, head := range heads { + _, queued := b.queuedChanges[ChangeHash(head)] + if !b.state.hasChange(ChangeHash(head)) && !queued { + missing[head] = struct{}{} + } + } + + for _, change := range b.queuedChanges { + for _, dependency := range change.Dependencies { + if !b.state.hasChange(dependency) { + missing[[32]byte(dependency)] = struct{}{} + } + } + } + + result := make([][32]byte, 0, len(missing)) + for dependency := range missing { + result = append(result, dependency) + } + + sort.Slice(result, func(i, j int) bool { + return bytes.Compare(result[i][:], result[j][:]) < 0 + }) + + return result, nil +} + func (b *Backend) ChangesSince( ctx context.Context, heads [][32]byte, @@ -527,6 +3695,17 @@ func (b *Backend) Merge(ctx context.Context, data []byte) ([][32]byte, error) { return nil, err } + // While isolated, merged changes belong to the full history rather than the + // pinned view, so operate on the full state and keep the pinned view intact. + if b.isolationActive && b.fullState != nil { + b.state, b.fullState = b.fullState, b.state + + defer func() { + b.state, b.fullState = b.fullState, b.state + b.nextOp = b.fullState.maxOpGlobal() + 1 + }() + } + document, err := Decode(data) if err != nil { document, err = DecodePartial(data) @@ -543,18 +3722,133 @@ func (b *Backend) Merge(ctx context.Context, data []byte) ([][32]byte, error) { } b.state = state + b.nextOp = state.maxOpGlobal() + 1 b.base = append([]byte(nil), data...) b.appended = nil + b.saveCursor = 0 + + return b.Heads(ctx) + } + + if b.requiresSnapshotMerge(document) { + if err := b.mergeDocumentSnapshot(data, document); err != nil { + return nil, err + } + + return b.Heads(ctx) + } + + if err := b.applyMergedChanges(document.Changes); err != nil { + return nil, err + } + + if next := b.state.maxOpGlobal() + 1; next > b.nextOp { + b.nextOp = next + } + + return b.Heads(ctx) +} + +func (b *Backend) requiresSnapshotMerge(document *Document) bool { + if len(document.ChunkTypes) == 0 || + document.ChunkTypes[0] != ChunkDocument { + return false + } + + for i := range document.Changes { + change := &document.Changes[i] + if change.Hash != nil && + !b.state.hasChange(*change.Hash) && + len(change.Raw) == 0 { + return true + } + } + + return false +} + +func (b *Backend) mergeDocumentSnapshot( + data []byte, + document *Document, +) error { + localChanges, ok := b.state.allChanges() + if !ok { + return fmt.Errorf("cannot enumerate local changes for snapshot merge") + } + + state, err := NewStateFromDocument(document) + if err != nil { + return fmt.Errorf("cannot initialize merged snapshot state: %w", err) + } + + for _, change := range localChanges { + if change.Hash == nil || state.hasChange(*change.Hash) { + continue + } + + incoming := documentChangeByActorSequence( + document, + change.Actor, + change.Sequence, + ) + if incoming != nil { + state.changes[*change.Hash] = incoming + } + } + + appended := make([][]byte, 0) + + for _, change := range localChanges { + if change.Hash == nil || + state.hasChange(*change.Hash) || + documentChangeByActorSequence( + document, + change.Actor, + change.Sequence, + ) != nil { + continue + } + + if len(change.Raw) == 0 { + return fmt.Errorf( + "cannot preserve local change %s during snapshot merge", + change.Hash, + ) + } + + if err := state.ApplyChange(change); err != nil { + return fmt.Errorf("cannot apply local change to merged snapshot: %w", err) + } + + appended = append(appended, append([]byte(nil), change.Raw...)) + } + + b.state = state + + b.base = append([]byte(nil), data...) + b.appended = appended + b.saveCursor = 0 + b.queuedChanges = make(map[ChangeHash]*Change) + b.queuedBytes = 0 + b.nextOp = state.maxOpGlobal() + 1 - return b.Heads(ctx) - } + return nil +} - if err := b.applyMergedChanges(document.Changes); err != nil { - return nil, err +func documentChangeByActorSequence( + document *Document, + actor ActorID, + sequence uint64, +) *Change { + for i := range document.Changes { + change := &document.Changes[i] + if change.Actor == actor && change.Sequence == sequence { + return change + } } - return b.Heads(ctx) + return nil } func (b *Backend) applyMergedChanges(changes []Change) error { @@ -642,6 +3936,56 @@ func (b *Backend) CloseSyncState(ctx context.Context, handle uint32) error { return nil } +func (b *Backend) SetSyncReadOnly( + ctx context.Context, + handle uint32, + readOnly bool, +) error { + if err := ctx.Err(); err != nil { + return err + } + + state, err := b.syncState(handle) + if err != nil { + return err + } + + if state.ReadOnly == readOnly { + return nil + } + + if state.ReadOnly && !readOnly { + peerSupportsReset := state.PeerSupportsReset + *state = nativeSyncState{ + PeerSupportsReset: peerSupportsReset, + NeedsReset: true, + ModeChanged: true, + } + } else { + state.ReadOnly = true + state.InFlight = false + state.ModeChanged = true + } + + return nil +} + +func (b *Backend) SyncPeerReadOnly( + ctx context.Context, + handle uint32, +) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + + state, err := b.syncState(handle) + if err != nil { + return false, err + } + + return state.PeerReadOnly, nil +} + func (b *Backend) GenerateSyncMessage( ctx context.Context, handle uint32, @@ -655,25 +3999,85 @@ func (b *Backend) GenerateSyncMessage( return nil, false, err } - if state.InFlight { - return nil, false, nil - } - heads, err := b.Heads(ctx) if err != nil { return nil, false, err } - if !state.NeedsAck && equalHashes(heads, state.RemoteHeads) { + // A message is only truly in flight while we have nothing new to say. New + // local changes (heads advanced past the last sent frontier) must be sent + // even while a previous message awaits acknowledgement, matching upstream + // Rust, which never withholds local changes during synchronization. + if state.InFlight && + !state.ModeChanged && + !state.NeedsReset && + len(state.Requested) == 0 && + len(state.Need) == 0 && + equalHashes(heads, state.LastSentHeads) { return nil, false, nil } + if state.ModeChanged || state.NeedsReset { + state.InFlight = false + } + + // The first message for a sync state is always sent so the peer learns our + // heads and capabilities, matching upstream Rust's first_response_is_some + // behavior. Subsequent messages may be suppressed when nothing is pending. + if state.Sent { + if state.PeerReadOnly && + !state.PeerModeChanged && + !state.ModeChanged && + !state.NeedsReset && + !state.NeedsAck && + len(state.Requested) == 0 && + len(state.Need) == 0 && + equalHashes(heads, state.LastSentHeads) { + return nil, false, nil + } + + if state.ReadOnly && + !state.ModeChanged && + !state.NeedsReset && + !state.NeedsAck && + len(state.Requested) == 0 && + len(state.Need) == 0 && + equalHashes(heads, state.LastSentHeads) { + return nil, false, nil + } + + if !state.NeedsAck && + !state.ModeChanged && + !state.NeedsReset && + len(state.Requested) == 0 && + len(state.Need) == 0 && + equalHashes(heads, state.RemoteHeads) { + return nil, false, nil + } + } + + flags := byte(syncFlagSupportsReset) + if state.ReadOnly { + flags |= syncFlagReadOnly + } + + messageHeads := heads + + if state.NeedsReset { + if state.PeerSupportsReset { + flags |= syncFlagReset + } else { + messageHeads = nil + } + } + message := SyncMessage{ Version: SyncMessageVersion2, - Heads: heads, + Heads: messageHeads, Need: append([][32]byte(nil), state.Need...), + Flags: []byte{2, syncFlagMarker | flags}, } - if !state.NeedsAck { + if !state.NeedsAck && !state.PeerReadOnly { switch { case len(state.Requested) > 0: for _, requested := range state.Requested { @@ -719,6 +4123,11 @@ func (b *Backend) GenerateSyncMessage( } state.NeedsAck = false + state.Sent = true + state.LastSentHeads = append(state.LastSentHeads[:0], heads...) + state.PeerModeChanged = false + state.ModeChanged = false + state.NeedsReset = false data, err := message.Encode() if err != nil { @@ -745,9 +4154,26 @@ func (b *Backend) ReceiveSyncMessage( state.InFlight = false - for _, change := range message.Changes { - if _, err := b.Merge(ctx, change); err != nil { - return fmt.Errorf("cannot merge native sync payload: %w", err) + flags := syncMessageFlagBits(message.Flags) + + peerReadOnly := flags&syncFlagReadOnly != 0 + if peerReadOnly != state.PeerReadOnly { + state.PeerModeChanged = true + } + + state.PeerReadOnly = peerReadOnly + + state.PeerSupportsReset = flags&syncFlagSupportsReset != 0 + if flags&syncFlagReset != 0 { + state.RemoteHeads = nil + state.Requested = nil + } + + if !state.ReadOnly { + for _, change := range message.Changes { + if _, err := b.Merge(ctx, change); err != nil { + return fmt.Errorf("cannot merge native sync payload: %w", err) + } } } @@ -756,16 +4182,18 @@ func (b *Backend) ReceiveSyncMessage( needed := make(map[[32]byte]struct{}) - for _, head := range message.Heads { - if _, ok := b.state.changes[ChangeHash(head)]; !ok { - needed[head] = struct{}{} + if !state.ReadOnly { + for _, head := range message.Heads { + if _, ok := b.state.changes[ChangeHash(head)]; !ok { + needed[head] = struct{}{} + } } - } - for _, change := range b.queuedChanges { - for _, dependency := range change.Dependencies { - if !b.state.hasChange(dependency) { - needed[[32]byte(dependency)] = struct{}{} + for _, change := range b.queuedChanges { + for _, dependency := range change.Dependencies { + if !b.state.hasChange(dependency) { + needed[[32]byte(dependency)] = struct{}{} + } } } } @@ -779,7 +4207,7 @@ func (b *Backend) ReceiveSyncMessage( return bytes.Compare(state.Need[i][:], state.Need[j][:]) < 0 }) - state.NeedsAck = len(message.Changes) > 0 + state.NeedsAck = len(message.Changes) > 0 || state.PeerModeChanged return nil } @@ -818,6 +4246,11 @@ func (b *Backend) LoadSyncState( return 0, fmt.Errorf("cannot decode native sync state: %w", err) } + // A serialized state cannot retain an in-flight transport message. Allow + // the restored session to regenerate it instead of waiting forever for an + // acknowledgement that may have been lost with the previous process. + state.InFlight = false + handle := b.nextSyncState b.nextSyncState++ b.syncStates[handle] = &state @@ -836,10 +4269,13 @@ func (b *Backend) addPending(operation Operation) error { } func (b *Backend) nextOperationID() OpID { - return OpID{ + id := OpID{ Actor: b.actor, - Counter: b.state.maxOpGlobal() + 1, + Counter: b.nextOp, } + b.nextOp++ + + return id } func (b *Backend) requireRoot(ctx context.Context, handle uint32) error { @@ -868,6 +4304,64 @@ func (b *Backend) object(handle uint32) (ObjectID, error) { return object, nil } +func (b *Backend) mapObject(handle uint32) (ObjectID, error) { + object, err := b.object(handle) + if err != nil { + return ObjectID{}, err + } + + if object.IsRoot { + return object, nil + } + + operation, ok := b.state.operations[object.OpID] + if !ok || + (operation.Action != ActionMakeMap && + operation.Action != ActionMakeTable) { + return ObjectID{}, fmt.Errorf("object is not a map") + } + + return object, nil +} + +func (b *Backend) sequenceObject(handle uint32) (ObjectID, error) { + object, err := b.object(handle) + if err != nil { + return ObjectID{}, err + } + + if object.IsRoot { + return ObjectID{}, fmt.Errorf("root map is not a sequence") + } + + operation, ok := b.state.operations[object.OpID] + if !ok || + (operation.Action != ActionMakeList && + operation.Action != ActionMakeText) { + return ObjectID{}, fmt.Errorf("object is not a sequence") + } + + return object, nil +} + +func (b *Backend) textObject(handle uint32) (ObjectID, error) { + object, err := b.object(handle) + if err != nil { + return ObjectID{}, err + } + + if object.IsRoot { + return ObjectID{}, fmt.Errorf("root map is not text") + } + + operation, ok := b.state.operations[object.OpID] + if !ok || operation.Action != ActionMakeText { + return ObjectID{}, fmt.Errorf("object is not text") + } + + return object, nil +} + func (b *Backend) pushObject(object ObjectID) uint32 { handle := b.nextHandle b.nextHandle++ @@ -893,13 +4387,266 @@ func (b *Backend) rootTextObjects() map[string]Operation { operation.Key.Property != nil && operation.Action == ActionMakeText && !b.state.isSuperseded(operation.ID) { - objects[*operation.Key.Property] = operation + property := *operation.Key.Property + + current, ok := objects[property] + if !ok || operation.ID.Compare(current.ID) > 0 { + objects[property] = operation + } } } return objects } +func (b *Backend) insertSequenceOperation( + ctx context.Context, + handle uint32, + index uint64, + action Action, + value *Scalar, +) (Operation, error) { + if err := ctx.Err(); err != nil { + return Operation{}, err + } + + object, err := b.sequenceObject(handle) + if err != nil { + return Operation{}, err + } + + sequence := b.state.sequenceValues(object.OpID) + + element, ok := b.resolveSequenceIndex(object, sequence, index) + if !ok || element > uint64(len(sequence)) { + return Operation{}, fmt.Errorf( + "sequence index %d is out of bounds for length %d", + index, + len(sequence), + ) + } + + key := Key{IsHead: element == 0} + if element > 0 { + key.Element = new(sequence[element-1].Element) + } + + operation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: key, + Insert: true, + Action: action, + Value: value, + } + if err := b.addPending(operation); err != nil { + return Operation{}, err + } + + return operation, nil +} + +// sequenceElementPredecessors returns the IDs of every visible operation at the +// list element, in ascending order. A put, delete, or increment must reference +// all of them so that concurrent conflicting values are overwritten identically +// to upstream Rust. +func (b *Backend) sequenceElementPredecessors(element OpID) []OpID { + visible := b.state.visibleSequenceElementOperations(element) + predecessors := make([]OpID, 0, len(visible)) + + for _, operation := range visible { + predecessors = append(predecessors, operation.ID) + } + + return predecessors +} + +func (b *Backend) sequenceOperation( + ctx context.Context, + handle uint32, + index uint64, +) (sequenceValue, error) { + if err := ctx.Err(); err != nil { + return sequenceValue{}, err + } + + object, err := b.sequenceObject(handle) + if err != nil { + return sequenceValue{}, err + } + + sequence := b.state.sequenceValues(object.OpID) + + element, ok := b.resolveSequenceIndex(object, sequence, index) + if !ok || element >= uint64(len(sequence)) { + return sequenceValue{}, fmt.Errorf( + "sequence index %d is out of bounds for length %d", + index, + len(sequence), + ) + } + + return sequence[element], nil +} + +// resolveSequenceIndex maps a caller-supplied index to a raw element index in +// the visible sequence. Text objects address positions in UTF-16 code units to +// match the reference encoding, so the index is translated to the element that +// begins at that code-unit boundary (a position inside a surrogate pair is +// advanced to the following boundary, as upstream Rust does); other sequences +// use element indices directly. The boolean reports whether the index resolves +// to a boundary at or before the end of the sequence. +func (b *Backend) resolveSequenceIndex( + object ObjectID, + sequence []sequenceValue, + index uint64, +) (uint64, bool) { + if !b.isTextObject(object) { + return index, true + } + + position := uint64(0) + + for i, value := range sequence { + if position == index { + return uint64(i), true + } + + position += sequenceValueUTF16Width(value) + if position > index { + return uint64(i + 1), true + } + } + + if position == index { + return uint64(len(sequence)), true + } + + return 0, false +} + +func (b *Backend) isTextObject(object ObjectID) bool { + if object.IsRoot { + return false + } + + operation, ok := b.state.operations[object.OpID] + + return ok && operation.Action == ActionMakeText +} + +func sequenceValueUTF16Width(value sequenceValue) uint64 { + operation := value.Operation + if operation.Value != nil && operation.Value.Type == ScalarString { + return uint64(utf16Width(operation.Value.String)) + } + + return 1 +} + +func objectAction(rawType string) (Action, error) { + switch rawType { + case "map": + return ActionMakeMap, nil + case "list": + return ActionMakeList, nil + case "text": + return ActionMakeText, nil + case "table": + return ActionMakeTable, nil + default: + return 0, fmt.Errorf("unknown object type %q", rawType) + } +} + +func actionObjectType(action Action) (string, error) { + switch action { + case ActionMakeMap: + return "map", nil + case ActionMakeList: + return "list", nil + case ActionMakeText: + return "text", nil + case ActionMakeTable: + return "table", nil + default: + return "", fmt.Errorf("operation is not an object") + } +} + +func (b *Backend) textMarkKey( + object ObjectID, + index uint32, +) (Key, error) { + // Mark positions share the unified rich-text index space with splice and + // block operations, so block markers occupy a position (length 1) just like + // a character. Walk the full element sequence, not the text-only view. + sequence := b.state.sequenceElements(object.OpID) + + _, previous, err := richTextPosition(sequence, index) + if err != nil { + return Key{}, err + } + + if previous == nil { + return Key{IsHead: true}, nil + } + + return Key{Element: new(*previous)}, nil +} + +func markExpansion(value string) (bool, bool, error) { + switch value { + case "before": + return true, false, nil + case "after": + return false, true, nil + case "both": + return true, true, nil + case "none": + return false, false, nil + default: + return false, false, fmt.Errorf("unknown mark expansion %q", value) + } +} + +func richTextPosition( + sequence []Operation, + index uint32, +) (*Operation, *OpID, error) { + var ( + position uint32 + previous *OpID + ) + + for i := range sequence { + operation := &sequence[i] + if position == index { + return operation, previous, nil + } + + length := uint32(utf16Length(*operation)) + if operation.Action == ActionMakeMap { + length = 1 + } + + if position+length > index { + return nil, nil, fmt.Errorf( + "rich-text index splits a Unicode character or block", + ) + } + + position += length + previous = new(operation.ID) + } + + if position != index { + return nil, nil, fmt.Errorf("rich-text index %d is out of bounds", index) + } + + return nil, previous, nil +} + func sequenceRange( sequence []Operation, index uint32, @@ -908,7 +4655,10 @@ func sequenceRange( position := uint32(0) start := -1 - var previous *OpID + var ( + previous *OpID + previousValue OpID + ) for i, operation := range sequence { if position == index { @@ -916,13 +4666,22 @@ func sequenceRange( break } - length := uint32(utf16Length(operation)) + length := elementLength(operation) if position+length > index { - return 0, 0, nil, fmt.Errorf("text index splits a Unicode character") + // UTF-16 callers can address the middle of a surrogate pair. + // Upstream Rust advances such a position to the boundary after + // the character rather than rejecting the edit. + position += length + previousValue = operation.ID + previous = &previousValue + start = i + 1 + + break } position += length - previous = new(operation.ID) + previousValue = operation.ID + previous = &previousValue } if start == -1 { @@ -933,31 +4692,149 @@ func sequenceRange( start = len(sequence) } - target := index + deleteCount + target := position + deleteCount end := start for end < len(sequence) && position < target { - position += uint32(utf16Length(sequence[end])) - if position > target { - return 0, 0, nil, fmt.Errorf("text deletion splits a Unicode character") - } - + position += elementLength(sequence[end]) end++ } - if position != target { + if position < target { return 0, 0, nil, fmt.Errorf("text deletion extends beyond the document") } return start, end, previous, nil } +// elementLength returns the position an operation occupies in the unified +// rich-text index space: block markers count as a single position, while text +// characters count by their UTF-16 code-unit length. +func elementLength(operation Operation) uint32 { + if operation.Action == ActionMakeMap { + return 1 + } + + return uint32(utf16Length(operation)) +} + func utf16Length(operation Operation) int { if operation.Value == nil || operation.Value.Type != ScalarString { return 0 } - return len(utf16.Encode([]rune(operation.Value.String))) + length := 0 + + for _, character := range operation.Value.String { + if character > 0xffff { + length += 2 + } else { + length++ + } + } + + return length +} + +func decodeScalarWire(encoded []byte) (Scalar, error) { + var wire scalarWire + if err := json.Unmarshal(encoded, &wire); err != nil { + return Scalar{}, fmt.Errorf("cannot decode scalar: %w", err) + } + + value := Scalar{ + Bool: wire.Bool, + Uint: wire.Uint, + Int: wire.Int, + Float: math.Float64frombits(wire.Float), + String: wire.String, + } + switch wire.Type { + case "null": + value.Type = ScalarNull + case "boolean": + if wire.Bool { + value.Type = ScalarTrue + } else { + value.Type = ScalarFalse + } + case "uint": + value.Type = ScalarUint + case "int": + value.Type = ScalarInt + case "float64": + value.Type = ScalarFloat64 + case "string": + value.Type = ScalarString + case "bytes": + value.Type = ScalarBytes + + bytes, err := hex.DecodeString(wire.Bytes) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode scalar bytes: %w", err) + } + + value.Bytes = bytes + case "counter": + value.Type = ScalarCounter + case "timestamp": + value.Type = ScalarTimestamp + default: + return Scalar{}, fmt.Errorf("unknown scalar type %q", wire.Type) + } + + return value, nil +} + +func encodeScalarWire(value Scalar) ([]byte, error) { + wire := scalarWire{ + Bool: value.Bool, + Uint: value.Uint, + Int: value.Int, + Float: math.Float64bits(value.Float), + String: value.String, + Bytes: hex.EncodeToString(value.Bytes), + } + switch value.Type { + case ScalarNull: + wire.Type = "null" + case ScalarFalse, ScalarTrue: + wire.Type = "boolean" + wire.Bool = value.Type == ScalarTrue + case ScalarUint: + wire.Type = "uint" + case ScalarInt: + wire.Type = "int" + case ScalarFloat64: + wire.Type = "float64" + case ScalarString: + wire.Type = "string" + case ScalarBytes: + wire.Type = "bytes" + case ScalarCounter: + wire.Type = "counter" + case ScalarTimestamp: + wire.Type = "timestamp" + default: + return nil, fmt.Errorf("unsupported scalar type %d", value.Type) + } + + encoded, err := json.Marshal(wire) + if err != nil { + return nil, fmt.Errorf("cannot encode scalar: %w", err) + } + + return encoded, nil +} + +func scalarValuesEqual(left, right Scalar) bool { + return left.Type == right.Type && + left.Bool == right.Bool && + left.Uint == right.Uint && + left.Int == right.Int && + math.Float64bits(left.Float) == math.Float64bits(right.Float) && + left.String == right.String && + bytes.Equal(left.Bytes, right.Bytes) } func randomActorID() (ActorID, error) { @@ -1033,3 +4910,24 @@ func equalHashes(left, right [][32]byte) bool { return true } + +func nativeHashes(heads [][32]byte) []ChangeHash { + result := make([]ChangeHash, len(heads)) + for i, head := range heads { + result[i] = ChangeHash(head) + } + + return result +} + +func syncMessageFlagBits(flags []byte) byte { + var bits byte + + for _, flag := range flags { + if flag&syncFlagMarker != 0 { + bits |= flag &^ syncFlagMarker + } + } + + return bits +} diff --git a/pkg/automerge/internal/native/columns.go b/pkg/automerge/internal/native/columns.go index fc64c6a72f..636facffb7 100644 --- a/pkg/automerge/internal/native/columns.go +++ b/pkg/automerge/internal/native/columns.go @@ -480,6 +480,27 @@ func readColumns(r *reader, metadata []columnMeta) (map[uint32]column, error) { return columns, nil } +// deflate compresses data with raw DEFLATE at best compression, matching the +// stream inflate reads. It is used to produce compressed change chunks. +func deflate(data []byte) ([]byte, error) { + var buffer bytes.Buffer + + writer, err := flate.NewWriter(&buffer, flate.BestCompression) + if err != nil { + return nil, fmt.Errorf("cannot create DEFLATE writer: %w", err) + } + + if _, err := writer.Write(data); err != nil { + return nil, fmt.Errorf("cannot write DEFLATE stream: %w", err) + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("cannot finish DEFLATE stream: %w", err) + } + + return buffer.Bytes(), nil +} + func inflate(data []byte) ([]byte, error) { compressed := flate.NewReader(bytes.NewReader(data)) defer func() { _ = compressed.Close() }() diff --git a/pkg/automerge/internal/native/decode.go b/pkg/automerge/internal/native/decode.go index b79202217c..4b7a94b89e 100644 --- a/pkg/automerge/internal/native/decode.go +++ b/pkg/automerge/internal/native/decode.go @@ -55,6 +55,33 @@ func DecodePartial(data []byte) (*Document, error) { return decode(data, false) } +// DecodeIncremental parses the complete chunk prefix and ignores an incomplete +// or corrupt trailing fragment after at least one valid chunk. +func DecodeIncremental(data []byte) (*Document, int, error) { + r := &reader{data: data} + consumed := 0 + + for r.remaining() > 0 { + start := r.offset + if _, err := decodeChunk(r); err != nil { + r.offset = start + break + } + + consumed = r.offset + } + + if consumed == 0 { + document, err := DecodePartial(data) + + return document, 0, err + } + + document, err := DecodePartial(data[:consumed]) + + return document, consumed, err +} + func decode(data []byte, validateHistory bool) (*Document, error) { if len(data) == 0 { return nil, fmt.Errorf("automerge file is empty") @@ -501,15 +528,15 @@ func decodeChangeChunk( return Change{}, nil, nil, err } - if len(operations) == 0 { - return Change{}, nil, nil, fmt.Errorf("change contains no operations") - } + maxOp := startOp - 1 + if len(operations) > 0 { + if startOp > math.MaxUint64-uint64(len(operations))+1 { + return Change{}, nil, nil, fmt.Errorf("operation range overflows uint64") + } - if startOp > math.MaxUint64-uint64(len(operations))+1 { - return Change{}, nil, nil, fmt.Errorf("operation range overflows uint64") + maxOp = startOp + uint64(len(operations)) - 1 } - maxOp := startOp + uint64(len(operations)) - 1 for i, operation := range operations { expected := startOp + uint64(i) if operation.ID.Actor != actor || operation.ID.Counter != expected { diff --git a/pkg/automerge/internal/native/decode_test.go b/pkg/automerge/internal/native/decode_test.go index ec9767a40a..c79a996545 100644 --- a/pkg/automerge/internal/native/decode_test.go +++ b/pkg/automerge/internal/native/decode_test.go @@ -25,6 +25,7 @@ import ( "compress/flate" "context" "encoding/base64" + "strings" "testing" "time" @@ -149,6 +150,106 @@ func TestDecode_CompressedOfficialChangeFixture(t *testing.T) { assert.Equal(t, "99c38e85f3aae8af5fc91b50329124c399d11a23eb834fe148b237280e4ba8a7", document.Heads[0].String()) } +func TestDecode_OfficialStorageCorpus(t *testing.T) { + t.Parallel() + + valid := []string{ + "counter_value_is_ok.automerge", + "two_change_chunks.automerge", + "two_change_chunks_compressed.automerge", + "two_change_chunks_out_of_order.automerge", + } + for _, name := range valid { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := base64.StdEncoding.DecodeString( + officialStorageFixtures[name], + ) + require.NoError(t, err) + _, err = Decode(data) + require.NoError(t, err) + }) + } + + invalid := []string{ + "counter_value_has_incorrect_meta.automerge", + "counter_value_is_overlong.automerge", + } + for _, name := range invalid { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := base64.StdEncoding.DecodeString( + officialStorageFixtures[name], + ) + require.NoError(t, err) + _, err = Decode(data) + require.Error(t, err) + }) + } +} + +func TestDecode_OfficialFuzzCrashersDoNotPanic(t *testing.T) { + t.Parallel() + + for name, encoded := range officialStorageFixtures { + if !strings.HasPrefix(name, "fuzz-") { + continue + } + + name := name + encoded := encoded + + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + + _, _ = Decode(data) + }) + } +} + +func TestDecode_Official64BitObjectIDs(t *testing.T) { + t.Parallel() + + for _, name := range []string{ + "64bit_obj_id_change.automerge", + "64bit_obj_id_doc.automerge", + } { + name := name + t.Run(name, func(t *testing.T) { + t.Parallel() + + data, err := base64.StdEncoding.DecodeString( + officialStorageFixtures[name], + ) + require.NoError(t, err) + + document, err := Decode(data) + if err != nil { + return + } + + require.NotEmpty(t, document.Changes) + + found := false + + for _, operation := range document.Changes[0].Operations { + if operation.ID.Counter == 1<<42 { + found = true + } + } + + assert.True(t, found) + }) + } +} + func TestDecode_ReferenceBackendDocument(t *testing.T) { t.Parallel() diff --git a/pkg/automerge/internal/native/encode.go b/pkg/automerge/internal/native/encode.go index 704976a519..9f6578fa85 100644 --- a/pkg/automerge/internal/native/encode.go +++ b/pkg/automerge/internal/native/encode.go @@ -153,11 +153,14 @@ func encodeOperationColumns( actions := make([]optional[uint64], count) valueMetadata := make([]optional[uint64], count) predGroups := make([]optional[uint64], count) + markExpands := make([]bool, count) + markNames := make([]optional[string], count) var ( - valueData []byte - predActors []optional[uint64] - predCounters []optional[int64] + valueData []byte + predActors []optional[uint64] + predCounters []optional[int64] + hasMarkExpand bool ) for i, operation := range change.Operations { @@ -210,6 +213,20 @@ func encodeOperationColumns( predActors = append(predActors, some(actorIndex)) predCounters = append(predCounters, some(int64(predecessor.Counter))) } + + if operation.MarkExpand != nil { + markExpands[i] = *operation.MarkExpand + hasMarkExpand = true + } + + if operation.MarkName != nil { + markNames[i] = some(*operation.MarkName) + } + } + + var markExpandData []byte + if hasMarkExpand { + markExpandData = encodeBooleans(markExpands) } columns := []encodedColumn{ @@ -225,6 +242,8 @@ func encodeOperationColumns( {specification: 112, data: encodeRLE(predGroups, appendULEB)}, {specification: 113, data: encodeRLE(predActors, appendULEB)}, {specification: 115, data: encodeDelta(predCounters)}, + {specification: 148, data: markExpandData}, + {specification: 165, data: encodeStrings(markNames)}, } filtered := columns[:0] @@ -290,6 +309,13 @@ func encodeRLE[T comparable]( values []optional[T], appendValue func([]byte, T) []byte, ) []byte { + if allNull(values) { + return nil + } + + // Change hashes cover these encoded bytes. Use the reference encoder's + // canonical run grouping so another implementation does not re-encode an + // otherwise valid change under a different hash. var data []byte for index := 0; index < len(values); { @@ -306,8 +332,31 @@ func encodeRLE[T comparable]( continue } + if index+1 < len(values) && + values[index+1].valid && + values[index+1].value == values[index].value { + end := index + 2 + for end < len(values) && + values[end].valid && + values[end].value == values[index].value { + end++ + } + + data = appendLEB(data, int64(end-index)) + data = appendValue(data, values[index].value) + index = end + + continue + } + end := index + 1 for end < len(values) && values[end].valid { + if end+1 < len(values) && + values[end+1].valid && + values[end+1].value == values[end].value { + break + } + end++ } @@ -319,10 +368,6 @@ func encodeRLE[T comparable]( index = end } - if allNull(values) { - return nil - } - return data } diff --git a/pkg/automerge/internal/native/encode_test.go b/pkg/automerge/internal/native/encode_test.go new file mode 100644 index 0000000000..4071ccb87a --- /dev/null +++ b/pkg/automerge/internal/native/encode_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package native + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestChangeEncodingExpandedRoundTrip reproduces the upstream +// test_change_encoding_expanded_change_round_trip: a change decoded from its +// canonical bytes re-encodes to exactly those bytes. +func TestChangeEncodingExpandedRoundTrip(t *testing.T) { + t.Parallel() + + changeBytes := []byte{ + 0x85, 0x6f, 0x4a, 0x83, // magic + 0xb2, 0x98, 0x9e, 0xa9, // checksum + 1, 61, 0, 2, 0x12, 0x34, // chunk type: change, length, deps, actor '1234' + 1, 1, 252, 250, 220, 255, 5, // seq, startOp, time + 14, 73, 110, 105, 116, 105, 97, 108, 105, 122, 97, 116, 105, 111, 110, // "Initialization" + 0, 6, // actor list, column count + 0x15, 3, 0x34, 1, 0x42, 2, // keyStr, insert, action + 0x56, 2, 0x57, 1, 0x70, 2, // valLen, valRaw, predNum + 0x7f, 1, 0x78, // keyStr: 'x' + 1, // insert: false + 0x7f, 1, // action: set + 0x7f, 19, // valLen: 1 byte of type uint + 1, // valRaw: 1 + 0x7f, 0, // predNum: 0 + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, // 10 trailing bytes inside the chunk + } + + document, consumed, err := DecodeIncremental(changeBytes) + require.NoError(t, err) + require.Equal(t, len(changeBytes), consumed) + require.Len(t, document.Changes, 1) + + encoded, err := EncodeChange(&document.Changes[0]) + require.NoError(t, err) + assert.Equal(t, changeBytes, encoded) +} + +func TestEncodeRLE_CanonicalRuns(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + values []optional[uint64] + want []byte + }{ + "all null": { + values: []optional[uint64]{{}, {}}, + want: nil, + }, + "null then value": { + values: []optional[uint64]{{}, {}, some(uint64(3))}, + want: []byte{0, 2, 0x7f, 3}, + }, + "repeated values": { + values: []optional[uint64]{some(uint64(0)), some(uint64(0))}, + want: []byte{2, 0}, + }, + "literal then repeated then literal": { + values: []optional[uint64]{ + some(uint64(1)), + some(uint64(2)), + some(uint64(2)), + some(uint64(3)), + }, + want: []byte{0x7f, 1, 2, 2, 0x7f, 3}, + }, + "literal values": { + values: []optional[uint64]{ + some(uint64(1)), + some(uint64(2)), + some(uint64(3)), + }, + want: []byte{0x7d, 1, 2, 3}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.want, encodeRLE(test.values, appendULEB)) + }) + } +} diff --git a/pkg/automerge/internal/native/fuzz_test.go b/pkg/automerge/internal/native/fuzz_test.go index de9fe001b1..a8b17c36d3 100644 --- a/pkg/automerge/internal/native/fuzz_test.go +++ b/pkg/automerge/internal/native/fuzz_test.go @@ -27,6 +27,25 @@ import ( "github.com/stretchr/testify/require" ) +var officialStorageFixtures = map[string]string{ + "64bit_obj_id_change.automerge": "hW9Kg2J1YNYBPwAQ2gpUVEJDSYSFAKXr4azZTQGAgICAgIABwb7Eg+EwCAFoYW5nZSAxAAUVAzQBQgJWAnACfwFhAX8AfwB/AA==", + "64bit_obj_id_doc.automerge": "hW9Kg1QlwfAAiAEBENoKVFRCQ0mEhQCl6+Gs2U0BYnVg1rgzMb6KmqiBrHdIhUya1snH32TnNnXqIPdqicoHAQIDAhMIIwc1CkACVgIHFQMhAiMINAFCAlYCgAECfwB/AX+AgICAgIABf8G+xIPhMH8IAWhhbmdlIDF/AH8HAAsEAAALAgt+DA0ADH8AAAIAC3wADHQAdQVieXRlcwVjb3VudAVmbG9hdANpbnQEbGlzdANuaWwCbm8EdGV4dAR1aW50BHdoZW4DeWVzAAQPAHQIAnx/BnYBBX0FegkDAQsEBAF/AgYBAgQCAXw3GIUBFAIAewFWE2kCAgACFgD/BwkAAAAAAAD4P3loZWxsbyr70JX/vDFhYg8AAA==", + "counter_value_has_incorrect_meta.automerge": "hW9Kgz5jZeYBNQAQiwZtoyQvRmChZG+IqRHxlAEBtLbS0OIwAAAGFQM0AUICVgJXAnACfwFhAX8BfygQf38A", + "counter_value_is_ok.automerge": "hW9Kg9Rz2qYBNQAQ/LFH/soQTf6flKoCf2h7awEBvvfR0OIwAAAGFQM0AUICVgJXAnACfwFhAX8BfyjQD38A", + "counter_value_is_overlong.automerge": "hW9Kg2/N3H0BNQAQiwZtoyQvRmChZG+IqRHxlAEBtLbS0OIwAAAGFQM0AUICVgJXAnACfwFhAX8BfyjQf38A", + "two_change_chunks.automerge": "hW9Kg5rD1zABOQAQ2gpUVEJDSYSFAKXr4azZTQEBwb7Eg+EwCGNoYW5nZSAxAAUVAzQBQgJWAnACfwFhAX8AfwB/AIVvSoOn5yfVAWQBmsPXMPJi2jXnHbWRuegVDemwKCba91AG8imJFq3sbgsQ2gpUVEJDSYSFAKXr4azZTQICwb7Eg+EwCGNoYW5nZSAyAAgBAgICFQM0AUICVgJXAXACfwB/AX8BYQF/AX8WYn8A", + "two_change_chunks_compressed.automerge": "hW9Kg5rD1zACPmIQuMUVEuLk7NnSyrD09cM1N30ZGQ/uO9L80IAjOSMxLz1VwZCBVZTZhNGJKYypgKmeMZGxngEEAQEAAP//hW9Kg6fnJ9UCbgBkAJv/AZrD1zDyYto15x21kbnoFQ3psCgm2vdQBvIpiRat7G4LENoKVFRCQ0mEhQCl6+Gs2U0CAsG+xIPhMAhjaGFuZ2UgMgAIAQICAhUDNAFCAlYCVwFwAn8AfwF/AWEBfwF/FmJ/AAEAAP//", + "two_change_chunks_out_of_order.automerge": "hW9Kg6fnJ9UBZAGaw9cw8mLaNecdtZG56BUN6bAoJtr3UAbyKYkWrexuCxDaClRUQkNJhIUApevhrNlNAgLBvsSD4TAIY2hhbmdlIDIACAECAgIVAzQBQgJWAlcBcAJ/AH8BfwFhAX8BfxZifwCFb0qDmsPXMAE5ABDaClRUQkNJhIUApevhrNlNAQHBvsSD4TAIY2hhbmdlIDEABRUDNAFCAlYCcAJ/AWEBfwB/AH8A", + "fuzz-action-is-48": "hW9Kg818x5kBMAAQMDAwMDAwMDAwMDAwMDAwMDAwMAAABhUDNAFCAlYCYQJwAjABMDABMH8G0A9/AA==", + "fuzz-empty-crash": "hW9Kg5ailtIAAA==", + "fuzz-incorrect-max-op": "hW9Kg/IrF9QAdAEQAlGmcMDRT1KAagbMI3V0owG3hG4vm1xsqt7I1lr4Yc0pMEkeiGUjKJAdUqx8qMyyJAYBAgMCEwIjAkACVgIIFQYhAiMCNAFCAlYCVwSAAQJ/AH8BfwB/AH8Afwd/BG8BfwF/AH8AAX8Bf0ZvAHBzfwAA", + "fuzz-invalid-deflate": "hW9KgzAwMDAAcQEQMDAwMDAwMDAwMDAwMDAwMAEwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAYBAgMCIAIwAjECMQIIIAQhAjACMQExAjkCVwOAAQJ/AH8BfwF/AH8Afwd/AmZ6fwB/AQF/AX8277+9fwAA", + "fuzz-missing-actor": "hW9Kgwdz5dgAdAEQ77C8VImLQtKPhfeZlnIU6AGYGdAqe3cyAAAAAAAAACH9xtoZ+f//AAuWa10o81nHmwYBAgMCEwIjAkACVgIIFQYhAiQCNAFCAlYCVwSAAQJ/BH8BfwF/AHcAfwd/BG8AcHN/AH8BAX8DOEZvb3DbfwAA", + "fuzz-overflow-length": "hW9Kgw1aCmMAqwEBAAAQAAAAAAAAAAEAAADj4+Pj4+PjhW9K4+PjhW9Kg+Pj4+Ph4+Nw1nBwcHBwg+MdGOPjL+HjSoPj4+Pj4ePWcHBwcHCD4x0Y4+Mv4+Pj//////8n////////////////////////AAAAAAAAAAAAAAD/////AAAAAAgAAAAAAAAAAAABAAAAAAQAAgEHXf/////////j4+PjBHBwcHBwAQABAAACAgddAQA=", + "fuzz-too-many-deps": "hW9Kg51nWyAAfAEQ77C8VImLQtKPhfeZcHIU6AGYGdAqe3fDi6Us1PDrRyH9xtoZAvksQgOWa10o81nHmwYBAgMCEwIjAkAKVgIIFQYhAiMCNAFCAlYCVwSAAQJ/AH8BfwF/AH/q6urq6urq6gB/B38EbwBwc38AfwEBuwF/Rm9vcHN/AAA=", + "fuzz-too-many-ops": "hW9Kg1XPQM0AfAEQ77C8VImbQtKPhfeZcHIU6AGYGdAqe3fDi6Us1PDrRyH9xtoZAvksQgOWa10o81nHmwYBAgMCEwIjAkACVgIIFQYhAiMCNAFCAlYCVwSAAQp/AH8BfwF/AH8Afwd/BG8AcHN/AH8BAX8Bf0Zvb3Bzf52dnZ2dnZ2dAAA=", +} + func FuzzDecode(f *testing.F) { change, err := base64.StdEncoding.DecodeString(officialChangeFixture) require.NoError(f, err) @@ -37,6 +56,12 @@ func FuzzDecode(f *testing.F) { f.Add(document) f.Add([]byte{0x85, 0x6f, 0x4a, 0x83}) + for _, encoded := range officialStorageFixtures { + data, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(f, err) + f.Add(data) + } + f.Fuzz(func(t *testing.T, data []byte) { if len(data) > 1024*1024 { t.Skip() @@ -45,3 +70,42 @@ func FuzzDecode(f *testing.F) { _, _ = Decode(data) }) } + +func FuzzParseSyncMessage(f *testing.F) { + message, err := (SyncMessage{ + Version: SyncMessageVersion2, + Heads: [][32]byte{{1}}, + Need: [][32]byte{{2}}, + Have: []SyncHave{ + { + LastSync: [][32]byte{{3}}, + Bloom: []byte{4, 5, 6}, + }, + }, + Changes: [][]byte{{7, 8, 9}}, + }).Encode() + require.NoError(f, err) + + f.Add(message) + f.Add([]byte{}) + f.Add([]byte{byte(SyncMessageVersion1)}) + f.Add([]byte{byte(SyncMessageVersion2)}) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1024*1024 { + t.Skip() + } + + parsed, err := ParseSyncMessage(data) + if err != nil { + return + } + + encoded, err := parsed.Encode() + if err != nil { + return + } + + _, _ = ParseSyncMessage(encoded) + }) +} diff --git a/pkg/automerge/internal/native/state.go b/pkg/automerge/internal/native/state.go index 2493f9967f..a23867fd53 100644 --- a/pkg/automerge/internal/native/state.go +++ b/pkg/automerge/internal/native/state.go @@ -24,6 +24,7 @@ import ( "bytes" "fmt" "reflect" + "slices" "sort" "strings" ) @@ -35,6 +36,7 @@ type ( operations map[OpID]Operation superseded map[OpID]struct{} heads map[ChangeHash]struct{} + sequenceCache map[OpID][]Operation } RichSpan struct { @@ -42,6 +44,11 @@ type ( Value any `json:"value"` Marks map[string]any `json:"marks,omitempty"` } + + sequenceValue struct { + Element OpID + Operation Operation + } ) func NewState() *State { @@ -51,6 +58,7 @@ func NewState() *State { operations: make(map[OpID]Operation), superseded: make(map[OpID]struct{}), heads: make(map[ChangeHash]struct{}), + sequenceCache: make(map[OpID][]Operation), } } @@ -74,7 +82,6 @@ func NewStateFromDocument(document *Document) (*State, error) { state.operations[operation.ID] = operation for _, successor := range operation.Successors { - state.superseded[operation.ID] = struct{}{} if successor.Counter == 0 { return nil, fmt.Errorf("invalid zero successor for operation %v", operation.ID) } @@ -82,6 +89,19 @@ func NewStateFromDocument(document *Document) (*State, error) { } } + for _, operation := range state.operations { + state.supersedePredecessors(operation) + + for _, successor := range operation.Successors { + successorOperation, ok := state.operations[successor] + if !ok || + successorOperation.Action != ActionIncrement || + !isCounterOperation(operation) { + state.superseded[operation.ID] = struct{}{} + } + } + } + for _, head := range document.Heads { state.heads[head] = struct{}{} } @@ -121,9 +141,11 @@ func (s *State) ApplyChange(change *Change) error { for _, operation := range change.Operations { s.operations[operation.ID] = operation - for _, predecessor := range operation.Predecessors { - s.superseded[predecessor] = struct{}{} + if !operation.Object.IsRoot { + delete(s.sequenceCache, operation.Object.OpID) } + + s.supersedePredecessors(operation) } s.changes[*change.Hash] = change @@ -174,13 +196,21 @@ func (s *State) Text(property string) (string, error) { } func (s *State) visibleMapOperation(property string, action Action) (Operation, bool) { + return s.visibleMapObjectOperation(RootObject(), property, action) +} + +func (s *State) visibleMapObjectOperation( + object ObjectID, + property string, + action Action, +) (Operation, bool) { var ( result Operation found bool ) for _, operation := range s.operations { - if !operation.Object.IsRoot || + if operation.Object != object || operation.Key.Property == nil || *operation.Key.Property != property || operation.Action != action || @@ -197,12 +227,112 @@ func (s *State) visibleMapOperation(property string, action Action) (Operation, return result, found } +func (s *State) visibleMapObjectValue( + object ObjectID, + property string, +) (Operation, bool) { + var ( + result Operation + found bool + ) + + for _, operation := range s.visibleMapObjectOperations(object, property) { + if operation.Action == ActionIncrement { + continue + } + + if !found || operation.ID.Compare(result.ID) > 0 { + result = operation + found = true + } + } + + return result, found +} + +func isCounterOperation(operation Operation) bool { + return operation.Action == ActionSet && + operation.Value != nil && + operation.Value.Type == ScalarCounter +} + +// supersedePredecessors marks the predecessors overwritten by operation. A +// regular operation supersedes all of its predecessors. An increment supersedes +// only its non-counter predecessors: incrementing a counter keeps it visible, +// but an increment that also references a conflicting non-counter value deletes +// that value, matching upstream Rust. +func (s *State) supersedePredecessors(operation Operation) { + for _, predecessor := range operation.Predecessors { + if operation.Action == ActionIncrement { + if pred, ok := s.operations[predecessor]; ok && isCounterOperation(pred) { + continue + } + } + + s.superseded[predecessor] = struct{}{} + } +} + +func (s *State) scalarValue(operation Operation) (Scalar, bool) { + if operation.Action != ActionSet || operation.Value == nil { + return Scalar{}, false + } + + value := *operation.Value + + value.Bytes = append([]byte(nil), operation.Value.Bytes...) + if value.Type != ScalarCounter { + return value, true + } + + for _, increment := range s.operations { + if increment.Action != ActionIncrement || + increment.Value == nil || + s.isSuperseded(increment.ID) { + continue + } + + matches := false + + for _, predecessor := range increment.Predecessors { + if predecessor == operation.ID { + matches = true + break + } + } + + if !matches { + for _, successor := range operation.Successors { + if successor == increment.ID { + matches = true + break + } + } + } + + if matches { + value.Int += increment.Value.Int + } + } + + return value, true +} + func (s *State) visibleMapOperations(property string) []Operation { + return s.visibleMapObjectOperations(RootObject(), property) +} + +func (s *State) visibleMapObjectOperations( + object ObjectID, + property string, +) []Operation { operations := make([]Operation, 0) for _, operation := range s.operations { - if !operation.Object.IsRoot || + if operation.Object != object || operation.Key.Property == nil || *operation.Key.Property != property || + operation.Action == ActionDelete || + operation.Action == ActionIncrement || s.isSuperseded(operation.ID) { continue } @@ -217,7 +347,37 @@ func (s *State) visibleMapOperations(property string) []Operation { return operations } +func (s *State) mapLength(object ObjectID) uint64 { + return uint64(len(s.mapKeys(object))) +} + +func (s *State) mapKeys(object ObjectID) []string { + properties := make(map[string]struct{}) + + for _, operation := range s.operations { + if operation.Object == object && + operation.Key.Property != nil && + operation.Action != ActionDelete && + !s.isSuperseded(operation.ID) { + properties[*operation.Key.Property] = struct{}{} + } + } + + keys := make([]string, 0, len(properties)) + for property := range properties { + keys = append(keys, property) + } + + slices.Sort(keys) + + return keys +} + func (s *State) sequence(object OpID) []Operation { + if cached, ok := s.sequenceCache[object]; ok { + return cached + } + operations := s.sequenceElements(object) result := operations[:0] @@ -227,9 +387,15 @@ func (s *State) sequence(object OpID) []Operation { } } + s.sequenceCache[object] = result + return result } +func (s *State) setSequenceCache(object OpID, operations []Operation) { + s.sequenceCache[object] = operations +} + func (s *State) sequenceElements(object OpID) []Operation { children := make(map[OpID][]Operation) @@ -301,6 +467,93 @@ func (s *State) sequenceAll(object OpID) []Operation { return operations } +func (s *State) sequenceValues(object OpID) []sequenceValue { + insertions := s.sequenceAll(object) + values := make([]sequenceValue, 0, len(insertions)) + + for _, insertion := range insertions { + var ( + value Operation + found bool + ) + if !s.isSuperseded(insertion.ID) { + value = insertion + found = true + } + + for _, operation := range s.operations { + if operation.Insert || + operation.Action == ActionDelete || + operation.Action == ActionIncrement || + operation.Key.Element == nil || + *operation.Key.Element != insertion.ID || + s.isSuperseded(operation.ID) { + continue + } + + if !found || operation.ID.Compare(value.ID) > 0 { + value = operation + found = true + } + } + + if found { + values = append( + values, + sequenceValue{ + Element: insertion.ID, + Operation: value, + }, + ) + } + } + + return values +} + +// visibleSequenceElementOperations returns every visible value operation whose +// list element is the given insertion, in ascending operation-ID order. This is +// the conflict set that a subsequent put, delete, or increment must reference as +// its predecessors, matching upstream Rust which references all visible ops. +func (s *State) visibleSequenceElementOperations(element OpID) []Operation { + var result []Operation + + if insertion, ok := s.operations[element]; ok && !s.isSuperseded(insertion.ID) { + result = append(result, insertion) + } + + for _, operation := range s.operations { + if operation.Insert || + operation.Action == ActionDelete || + operation.Action == ActionIncrement || + operation.Key.Element == nil || + *operation.Key.Element != element || + s.isSuperseded(operation.ID) { + continue + } + + result = append(result, operation) + } + + sort.Slice(result, func(i, j int) bool { + return result[i].ID.Compare(result[j].ID) < 0 + }) + + return result +} + +// sequenceConflicts returns every visible value operation at the given visible +// list index, i.e. the conflict set that get_all(index) exposes. The boolean is +// false when the index is out of range. +func (s *State) sequenceConflicts(object OpID, index uint64) ([]Operation, bool) { + values := s.sequenceValues(object) + if index >= uint64(len(values)) { + return nil, false + } + + return s.visibleSequenceElementOperations(values[index].Element), true +} + func (s *State) RichTextSpans(object OpID) ([]RichSpan, error) { elements := s.sequenceElements(object) marks := s.richTextMarks(object, elements) @@ -324,7 +577,11 @@ func (s *State) RichTextSpans(object OpID) ([]RichSpan, error) { for _, mark := range marks { if i >= mark.start && i < mark.end { - activeMarks[mark.name] = mark.value + if mark.value == nil { + delete(activeMarks, mark.name) + } else { + activeMarks[mark.name] = mark.value + } } } @@ -354,10 +611,19 @@ func (s *State) RichTextSpans(object OpID) ([]RichSpan, error) { } type richTextMark struct { - start int - end int - name string - value any + start int + end int + name string + value any + scalar *Scalar +} + +// MarkRange is one active mark over a UTF-16 range of a text object. +type MarkRange struct { + Start uint32 + End uint32 + Name string + Value *Scalar } func (s *State) richTextMarks(object OpID, elements []Operation) []richTextMark { @@ -381,29 +647,71 @@ func (s *State) richTextMarks(object OpID, elements []Operation) []richTextMark return markOperations[i].ID.Compare(markOperations[j].ID) < 0 }) + byID := make(map[OpID]Operation, len(markOperations)) + for _, operation := range markOperations { + byID[operation.ID] = operation + } + marks := make([]richTextMark, 0, len(markOperations)/2) - for i := 0; i+1 < len(markOperations); i += 2 { - begin := markOperations[i] + for _, begin := range markOperations { + if begin.MarkName == nil { + continue + } - end := markOperations[i+1] - if begin.MarkName == nil || begin.Key.Element == nil || end.Key.Element == nil { + end, ok := byID[OpID{ + Actor: begin.ID.Actor, + Counter: begin.ID.Counter + 1, + }] + if !ok || end.MarkName != nil { continue } - startPosition, startOK := positions[*begin.Key.Element] + startPosition, startOK := s.markAnchorPosition( + object, + begin.Key, + begin.ID, + true, + begin.MarkExpand != nil && *begin.MarkExpand, + positions, + elements, + true, + make(map[OpID]struct{}), + ) - endPosition, endOK := positions[*end.Key.Element] + endPosition, endOK := s.markAnchorPosition( + object, + end.Key, + end.ID, + false, + end.MarkExpand != nil && *end.MarkExpand, + positions, + elements, + true, + make(map[OpID]struct{}), + ) if !startOK || !endOK || startPosition >= endPosition { continue } + // If a mark started at the document head and every element that + // existed in its original range has since been deleted, replacement + // text inserted at the head must not inherit the mark. A visible + // left-hand anchor (or any surviving original marked element) keeps + // boundary expansion active; this distinction matches Rust's splice + // behavior when replacing some marked text versus all of it. + if begin.Key.IsHead && + !s.markRangeHasSurvivingElement(object, begin, end) { + continue + } + marks = append( marks, richTextMark{ - start: startPosition + 1, - end: endPosition + 1, - name: *begin.MarkName, - value: scalarMaterializedValue(begin.Value), + start: startPosition, + end: endPosition, + name: *begin.MarkName, + value: scalarMaterializedValue(begin.Value), + scalar: begin.Value, }, ) } @@ -411,6 +719,317 @@ func (s *State) richTextMarks(object OpID, elements []Operation) []richTextMark return marks } +// Marks returns the active marks over a text object as UTF-16 ranges, matching +// upstream Rust's marks(): contiguous runs of an identical (name, value) mark +// are merged, block markers occupy one position, and marks removed by a null +// value are excluded. +func (s *State) Marks(object OpID) []MarkRange { + elements := s.sequenceElements(object) + marks := s.richTextMarks(object, elements) + + type openMark struct { + start uint32 + value *Scalar + } + + open := make(map[string]openMark) + + result := make([]MarkRange, 0) + + var position uint32 + + closeMark := func(name string, mark openMark, end uint32) { + result = append(result, MarkRange{ + Start: mark.start, + End: end, + Name: name, + Value: mark.value, + }) + } + + for index, element := range elements { + active := make(map[string]*Scalar) + + for _, mark := range marks { + if index < mark.start || index >= mark.end { + continue + } + + if mark.scalar == nil || mark.scalar.Type == ScalarNull { + delete(active, mark.name) + } else { + active[mark.name] = mark.scalar + } + } + + for name, mark := range open { + value, ok := active[name] + if !ok || !scalarValuesEqual(*value, *mark.value) { + closeMark(name, mark, position) + delete(open, name) + } + } + + for name, value := range active { + if _, ok := open[name]; !ok { + open[name] = openMark{start: position, value: value} + } + } + + position += elementLength(element) + } + + for name, mark := range open { + closeMark(name, mark, position) + } + + sort.Slice(result, func(i, j int) bool { + if result[i].Start != result[j].Start { + return result[i].Start < result[j].Start + } + + return result[i].Name < result[j].Name + }) + + return result +} + +func (s *State) markRangeHasSurvivingElement( + object OpID, + begin Operation, + end Operation, +) bool { + elements := s.sequenceAll(object) + start := 0 + + if begin.Key.Element != nil { + for i, element := range elements { + if element.ID == *begin.Key.Element { + start = i + 1 + + break + } + } + } + + stop := len(elements) + if end.Key.Element != nil { + for i, element := range elements { + if element.ID == *end.Key.Element { + stop = i + 1 + + break + } + } + } + + if start > stop { + return false + } + + for _, element := range elements[start:stop] { + if element.ID.Compare(begin.ID) < 0 && + !s.isSuperseded(element.ID) { + return true + } + } + + return false +} + +// markOpUTF16Range returns the literal UTF-16 range a mark operation pair spans, +// without boundary-expansion adjustment. It is used to report mark and unmark +// operations as Mark patches, matching the reference's operation-based diff. +func (s *State) markOpUTF16Range(object OpID, begin, end Operation) (uint32, uint32, bool) { + elements := s.sequenceElements(object) + + positions := make(map[OpID]int, len(elements)) + for index, element := range elements { + positions[element.ID] = index + } + + beginExpand := begin.MarkExpand != nil && *begin.MarkExpand + endExpand := end.MarkExpand != nil && *end.MarkExpand + + startIndex, startOK := s.markAnchorPosition( + object, begin.Key, begin.ID, true, beginExpand, positions, elements, false, make(map[OpID]struct{}), + ) + endIndex, endOK := s.markAnchorPosition( + object, end.Key, end.ID, false, endExpand, positions, elements, false, make(map[OpID]struct{}), + ) + + if !startOK || !endOK { + return 0, 0, false + } + + return utf16PrefixLength(elements, startIndex), utf16PrefixLength(elements, endIndex), true +} + +// utf16PrefixLength sums the UTF-16 width of the first count elements, so a mark +// anchor expressed as an element index becomes a UTF-16 position. +func utf16PrefixLength(elements []Operation, count int) uint32 { + var position uint32 + + for i := 0; i < count && i < len(elements); i++ { + position += elementLength(elements[i]) + } + + return position +} + +func (s *State) markAnchorPosition( + object OpID, + key Key, + marker OpID, + start bool, + expand bool, + positions map[OpID]int, + elements []Operation, + adjustBoundary bool, + visited map[OpID]struct{}, +) (int, bool) { + if key.IsHead { + position := 0 + if adjustBoundary && ((start && !expand) || (!start && expand)) { + position = s.markBoundaryInsertionEnd(key, marker, position, elements) + } + + return position, true + } + + if key.Element == nil { + return 0, false + } + + if position, ok := positions[*key.Element]; ok { + position++ + if adjustBoundary && ((start && !expand) || (!start && expand)) { + position = s.markBoundaryInsertionEnd(key, marker, position, elements) + } + + return position, true + } + + if _, ok := visited[*key.Element]; ok { + return 0, false + } + + visited[*key.Element] = struct{}{} + + operation, ok := s.operations[*key.Element] + if !ok { + return 0, false + } + + if operation.Action == ActionMark { + return s.markAnchorPosition( + object, + operation.Key, + marker, + start, + expand, + positions, + elements, + false, + visited, + ) + } + + if expand { + position := 0 + + for _, element := range s.sequenceAll(object) { + if element.ID == operation.ID { + return position, true + } + + if !s.isSuperseded(element.ID) { + position++ + } + } + + return 0, false + } + + // A non-expanding marker anchored to a deleted element stays before + // insertions at that element's former position. Follow the deleted + // element's own predecessor chain to find that position. + return s.markAnchorPosition( + object, + operation.Key, + marker, + start, + expand, + positions, + elements, + false, + visited, + ) +} + +// markBoundaryInsertionEnd returns the position after insertion branches that +// were created at a mark boundary after the marker operation. Mark markers and +// ordinary sequence insertions share an anchor in the Automerge operation tree; +// their relative operation IDs determine which side of the marker a later +// insertion occupies. Expanding end markers and non-expanding begin markers sit +// after these branches, while the opposite expansion modes sit before them. +func (s *State) markBoundaryInsertionEnd( + key Key, + marker OpID, + position int, + elements []Operation, +) int { + for position < len(elements) { + child, ok := s.boundaryChild(elements[position], key, make(map[OpID]struct{})) + if !ok || child.Compare(marker) <= 0 { + break + } + + position++ + } + + return position +} + +// boundaryChild returns the direct insertion child of a boundary anchor for an +// element, following insertion ancestry through the sequence tree. +func (s *State) boundaryChild( + element Operation, + boundary Key, + visited map[OpID]struct{}, +) (OpID, bool) { + current := element + + for { + if _, ok := visited[current.ID]; ok { + return OpID{}, false + } + + visited[current.ID] = struct{}{} + + if boundary.IsHead && current.Key.IsHead { + return current.ID, true + } + + if boundary.Element != nil && + current.Key.Element != nil && + *current.Key.Element == *boundary.Element { + return current.ID, true + } + + if current.Key.Element == nil { + return OpID{}, false + } + + parent, ok := s.operations[*current.Key.Element] + if !ok || parent.Action == ActionMark { + return OpID{}, false + } + + current = parent + } +} + func (s *State) mapValue( object OpID, visited map[OpID]struct{}, @@ -606,6 +1225,36 @@ func (s *State) sequenceForActor(actor ActorID) uint64 { return s.actorSequence[actor] } +// maxOpForActor returns the highest operation counter authored by the actor in +// this state, or zero if the actor has no operations. It is used to decide +// whether an actor is fully covered by a set of heads when isolating writes. +func (s *State) maxOpForActor(actor ActorID) uint64 { + var maximum uint64 + + for id := range s.operations { + if id.Actor == actor && id.Counter > maximum { + maximum = id.Counter + } + } + + return maximum +} + +// hashForActorSequence returns the hash of the change authored by actor at the +// given sequence number, if it is known. +func (s *State) hashForActorSequence( + actor ActorID, + sequence uint64, +) (ChangeHash, bool) { + for hash, change := range s.changes { + if change.Actor == actor && change.Sequence == sequence { + return hash, true + } + } + + return ChangeHash{}, false +} + func (s *State) applyPending(operations []Operation) error { for _, operation := range operations { if _, exists := s.operations[operation.ID]; exists { @@ -613,9 +1262,11 @@ func (s *State) applyPending(operations []Operation) error { } s.operations[operation.ID] = operation - for _, predecessor := range operation.Predecessors { - s.superseded[predecessor] = struct{}{} + if !operation.Object.IsRoot { + delete(s.sequenceCache, operation.Object.OpID) } + + s.supersedePredecessors(operation) } return nil @@ -703,6 +1354,87 @@ func (s *State) changesSince(heads []ChangeHash) ([]*Change, bool) { return ordered, true } +func (s *State) allChanges() ([]*Change, bool) { + ordered := make([]*Change, 0, len(s.changes)) + visited := make(map[ChangeHash]struct{}, len(s.changes)) + + var visit func(ChangeHash) bool + + visit = func(hash ChangeHash) bool { + if _, ok := visited[hash]; ok { + return true + } + + change, ok := s.changes[hash] + if !ok { + return false + } + + for _, dependency := range change.Dependencies { + if !visit(dependency) { + return false + } + } + + visited[hash] = struct{}{} + + ordered = append(ordered, change) + + return true + } + + for _, head := range s.Heads() { + if !visit(head) { + return nil, false + } + } + + return ordered, len(visited) == len(s.changes) +} + +func (s *State) at(heads []ChangeHash) (*State, bool) { + target := NewState() + visited := make(map[ChangeHash]struct{}) + + var visit func(ChangeHash) bool + + visit = func(hash ChangeHash) bool { + if _, ok := visited[hash]; ok { + return true + } + + change, ok := s.changes[hash] + if !ok { + return false + } + + for _, dependency := range change.Dependencies { + if !visit(dependency) { + return false + } + } + + clone := *change + + clone.Hash = new(hash) + if err := target.ApplyChange(&clone); err != nil { + return false + } + + visited[hash] = struct{}{} + + return true + } + + for _, head := range heads { + if !visit(head) { + return nil, false + } + } + + return target, true +} + func (s *State) changeClosure(heads []ChangeHash) (map[ChangeHash]struct{}, bool) { closure := make(map[ChangeHash]struct{}) diff --git a/pkg/automerge/internal/native/sync_message_test.go b/pkg/automerge/internal/native/sync_message_test.go new file mode 100644 index 0000000000..5f2bf6a670 --- /dev/null +++ b/pkg/automerge/internal/native/sync_message_test.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package native + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSyncMessageEncodeDecodeEmptyV2 reproduces the upstream +// encode_decode_empty_message test (rust/automerge/src/sync.rs): an empty V2 +// sync message must encode and parse back without error. It additionally +// asserts the exact wire bytes so the native V2 codec stays byte-compatible +// with the reference, whose Message::encode writes the type byte followed by +// four zero ULEB collection counts (heads, need, have, changes) and no flags. +func TestSyncMessageEncodeDecodeEmptyV2(t *testing.T) { + t.Parallel() + + message := SyncMessage{Version: SyncMessageVersion2} + + encoded, err := message.Encode() + require.NoError(t, err) + assert.Equal(t, []byte{byte(SyncMessageVersion2), 0x00, 0x00, 0x00, 0x00}, encoded) + + parsed, err := ParseSyncMessage(encoded) + require.NoError(t, err) + assert.Equal(t, SyncMessageVersion2, parsed.Version) + assert.Empty(t, parsed.Heads) + assert.Empty(t, parsed.Need) + assert.Empty(t, parsed.Have) + assert.Empty(t, parsed.Changes) + assert.Nil(t, parsed.Flags) + + reencoded, err := parsed.Encode() + require.NoError(t, err) + assert.Equal(t, encoded, reencoded) +} diff --git a/pkg/automerge/internal/native/text_diff.go b/pkg/automerge/internal/native/text_diff.go new file mode 100644 index 0000000000..91959a4fee --- /dev/null +++ b/pkg/automerge/internal/native/text_diff.go @@ -0,0 +1,350 @@ +// Copyright (c) 2025 Probo Inc. +// +// 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. + +package native + +import ( + "context" + "strings" + + "github.com/rivo/uniseg" +) + +// UpdateText replaces the text content of the object with value using a minimal +// grapheme-aware Myers diff, mirroring the Rust AutoCommit::update_text helper. +// It computes the shortest sequence of splice operations transforming the +// current text into value so concurrent edits to untouched regions merge +// cleanly. Splice positions are expressed in UTF-16 code units, matching the +// default text encoding used by the reference backend. +func (b *Backend) UpdateText(ctx context.Context, handle uint32, value string) error { + if err := ctx.Err(); err != nil { + return err + } + + if _, err := b.textObject(handle); err != nil { + return err + } + + current, err := b.Text(ctx, handle) + if err != nil { + return err + } + + if current == value { + return nil + } + + oldGraphemes := graphemeClusters(current) + newGraphemes := graphemeClusters(value) + + hook := &textDiffHook{ctx: ctx, backend: b, handle: handle, old: oldGraphemes, new: newGraphemes} + myersDiff(hook, oldGraphemes, newGraphemes) + + return hook.err +} + +// graphemeClusters splits a string into UAX #29 extended grapheme clusters so +// that diffing operates on user-perceived characters (e.g. emoji ZWJ sequences) +// rather than code points, matching Rust's unicode-segmentation based diff. +func graphemeClusters(text string) []string { + if text == "" { + return nil + } + + clusters := make([]string, 0, len(text)) + state := -1 + + for len(text) > 0 { + var cluster string + + cluster, text, _, state = uniseg.FirstGraphemeClusterInString(text, state) + clusters = append(clusters, cluster) + } + + return clusters +} + +// utf16Width returns the number of UTF-16 code units required to encode a string. +func utf16Width(text string) int { + width := 0 + + for _, r := range text { + if r > 0xFFFF { + width += 2 + } else { + width++ + } + } + + return width +} + +// textDiffHook applies the edits produced by the Myers diff as splice +// operations on the target text object. +type textDiffHook struct { + ctx context.Context + backend *Backend + handle uint32 + old []string + new []string + idx int + err error +} + +func (h *textDiffHook) failed() bool { + return h.err != nil +} + +func (h *textDiffHook) equal(oldIndex, _ int, length int) { + for i := 0; i < length; i++ { + h.idx += utf16Width(h.old[oldIndex+i]) + } +} + +func (h *textDiffHook) delete(oldIndex, oldLen, _ int) { + if h.err != nil { + return + } + + deleted := 0 + + for i := 0; i < oldLen; i++ { + deleted += utf16Width(h.old[oldIndex+i]) + } + + if err := h.backend.SpliceText(h.ctx, h.handle, uint32(h.idx), int32(deleted), ""); err != nil { + h.err = err + } +} + +func (h *textDiffHook) insert(_ int, newIndex, newLen int) { + if h.err != nil { + return + } + + var builder strings.Builder + + for i := 0; i < newLen; i++ { + builder.WriteString(h.new[newIndex+i]) + } + + chars := builder.String() + if err := h.backend.SpliceText(h.ctx, h.handle, uint32(h.idx), 0, chars); err != nil { + h.err = err + return + } + + h.idx += utf16Width(chars) +} + +// diffSink receives the edit script produced by the Myers diff. Text and block +// reconciliation implement it over their respective element sequences. +type diffSink interface { + equal(oldIndex, newIndex, length int) + delete(oldIndex, oldLen, newIndex int) + insert(oldIndex, newIndex, newLen int) + failed() bool +} + +// myersDiff computes the difference between old and new using Myers' O((N+M)D) +// algorithm and reports edits to the hook. It is a direct port of the reference +// Rust implementation (copied there from the similar crate) so the emitted +// edit scriptβ€”and therefore the resulting changeβ€”matches byte for byte. +func myersDiff(hook diffSink, before, after []string) { + maximum := maxD(len(before), len(after)) + vf := newVArray(maximum) + vb := newVArray(maximum) + conquer(hook, before, 0, len(before), after, 0, len(after), vf, vb) +} + +type vArray struct { + offset int + values []int +} + +func newVArray(maximum int) *vArray { + return &vArray{offset: maximum, values: make([]int, 2*maximum)} +} + +func (a *vArray) get(k int) int { + return a.values[k+a.offset] +} + +func (a *vArray) set(k, value int) { + a.values[k+a.offset] = value +} + +func maxD(oldLen, newLen int) int { + return (oldLen+newLen+1)/2 + 1 +} + +func commonPrefixLen(before []string, oldStart, oldEnd int, after []string, newStart, newEnd int) int { + if oldStart >= oldEnd || newStart >= newEnd { + return 0 + } + + length := 0 + for oldStart+length < oldEnd && newStart+length < newEnd && before[oldStart+length] == after[newStart+length] { + length++ + } + + return length +} + +func commonSuffixLen(before []string, oldStart, oldEnd int, after []string, newStart, newEnd int) int { + if oldStart >= oldEnd || newStart >= newEnd { + return 0 + } + + length := 0 + for length < (oldEnd-oldStart) && length < (newEnd-newStart) && before[oldEnd-1-length] == after[newEnd-1-length] { + length++ + } + + return length +} + +func findMiddleSnake( + before []string, oldStart, oldEnd int, + after []string, newStart, newEnd int, + vf, vb *vArray, +) (int, int, bool) { + n := oldEnd - oldStart + m := newEnd - newStart + delta := n - m + odd := delta&1 == 1 + + vf.set(1, 0) + vb.set(1, 0) + + dMax := maxD(n, m) + + for d := 0; d < dMax; d++ { + for k := d; k >= -d; k -= 2 { + var x int + if k == -d || (k != d && vf.get(k-1) < vf.get(k+1)) { + x = vf.get(k + 1) + } else { + x = vf.get(k-1) + 1 + } + + y := x - k + + x0, y0 := x, y + if x < n && y < m { + advance := commonPrefixLen(before, oldStart+x, oldEnd, after, newStart+y, newEnd) + x += advance + } + + vf.set(k, x) + + if odd && abs(k-delta) <= d-1 { + if vf.get(k)+vb.get(-(k-delta)) >= n { + return x0 + oldStart, y0 + newStart, true + } + } + } + + for k := d; k >= -d; k -= 2 { + var x int + if k == -d || (k != d && vb.get(k-1) < vb.get(k+1)) { + x = vb.get(k + 1) + } else { + x = vb.get(k-1) + 1 + } + + y := x - k + + if x < n && y < m { + advance := commonSuffixLen(before, oldStart, oldStart+n-x, after, newStart, newStart+m-y) + x += advance + y += advance + } + + vb.set(k, x) + + if !odd && abs(k-delta) <= d { + if vb.get(k)+vf.get(-(k-delta)) >= n { + return n - x + oldStart, m - y + newStart, true + } + } + } + } + + return 0, 0, false +} + +func conquer( + hook diffSink, + before []string, oldStart, oldEnd int, + after []string, newStart, newEnd int, + vf, vb *vArray, +) { + if hook.failed() { + return + } + + prefix := commonPrefixLen(before, oldStart, oldEnd, after, newStart, newEnd) + if prefix > 0 { + hook.equal(oldStart, newStart, prefix) + } + + oldStart += prefix + newStart += prefix + + suffix := commonSuffixLen(before, oldStart, oldEnd, after, newStart, newEnd) + suffixOld := oldEnd - suffix + suffixNew := newEnd - suffix + oldEnd -= suffix + newEnd -= suffix + + switch { + case oldStart >= oldEnd && newStart >= newEnd: + // Nothing to do. + case newStart >= newEnd: + hook.delete(oldStart, oldEnd-oldStart, newStart) + case oldStart >= oldEnd: + hook.insert(oldStart, newStart, newEnd-newStart) + default: + if xStart, yStart, ok := findMiddleSnake(before, oldStart, oldEnd, after, newStart, newEnd, vf, vb); ok { + conquer(hook, before, oldStart, xStart, after, newStart, yStart, vf, vb) + conquer(hook, before, xStart, oldEnd, after, yStart, newEnd, vf, vb) + } else { + hook.delete(oldStart, oldEnd-oldStart, newStart) + hook.insert(oldStart, newStart, newEnd-newStart) + } + } + + if hook.failed() { + return + } + + if suffix > 0 { + hook.equal(suffixOld, suffixNew, suffix) + } +} + +func abs(value int) int { + if value < 0 { + return -value + } + + return value +} diff --git a/pkg/automerge/internal/native/types.go b/pkg/automerge/internal/native/types.go index 79a091bff2..48e36157cb 100644 --- a/pkg/automerge/internal/native/types.go +++ b/pkg/automerge/internal/native/types.go @@ -178,6 +178,26 @@ func (a ActorID) Compare(other ActorID) int { return bytes.Compare(a.Bytes(), other.Bytes()) } +// concurrencyMagicBytes prefixes actor IDs derived for isolated writes so they +// cannot collide with real actor IDs. It matches Rust's CONCURRENCY_MAGIC_BYTES. +var concurrencyMagicBytes = [4]byte{0x13, 0xb2, 0x23, 0x09} + +// WithConcurrency derives the isolation actor for the given concurrency level, +// mirroring Rust's ActorId::with_concurrency: the magic bytes, a ULEB128 level, +// then the base actor bytes. Level zero returns the base actor unchanged. +func (a ActorID) WithConcurrency(level uint64) ActorID { + if level == 0 { + return a + } + + bytes := make([]byte, 0, 4+16+len(a)) + bytes = append(bytes, concurrencyMagicBytes[:]...) + bytes = appendULEB(bytes, level) + bytes = append(bytes, a.Bytes()...) + + return ActorID(string(bytes)) +} + // String returns the lowercase hexadecimal change hash. func (h ChangeHash) String() string { return hex.EncodeToString(h[:]) diff --git a/pkg/automerge/internal/native/validate.go b/pkg/automerge/internal/native/validate.go index 9160150d35..64a1fd46e8 100644 --- a/pkg/automerge/internal/native/validate.go +++ b/pkg/automerge/internal/native/validate.go @@ -665,6 +665,22 @@ func validateActorSequences(changes []Change) error { ) } + if change.MaxOp < change.StartOp { + if change.MaxOp < previousMax { + return fmt.Errorf( + "actor %s sequence %d empty change maxOp %d precedes %d", + actor, + change.Sequence, + change.MaxOp, + previousMax, + ) + } + + previousMax = change.MaxOp + + continue + } + if change.MaxOp <= previousMax { return fmt.Errorf( "actor %s sequence %d has non-increasing maxOp %d", diff --git a/pkg/automerge/internal/reference/reference.go b/pkg/automerge/internal/reference/reference.go index 4657ea64b7..a9fdb6cfb5 100644 --- a/pkg/automerge/internal/reference/reference.go +++ b/pkg/automerge/internal/reference/reference.go @@ -26,6 +26,7 @@ import ( "crypto/sha256" _ "embed" "encoding/hex" + "encoding/json" "fmt" "strings" "sync" @@ -92,6 +93,23 @@ func Load(ctx context.Context, document []byte) (*Backend, error) { return backend, nil } +// LoadConvertingStrings loads a document, converting every string scalar in a +// map or list into a text object, mirroring StringMigration::ConvertToText. +func LoadConvertingStrings(ctx context.Context, document []byte) (*Backend, error) { + backend, err := instantiate(ctx) + if err != nil { + return nil, fmt.Errorf("cannot instantiate Automerge reference backend: %w", err) + } + + if err := backend.runBytes(ctx, "am_load_convert_strings", document); err != nil { + _ = backend.Close(ctx) + + return nil, fmt.Errorf("cannot load Automerge document with string migration: %w", err) + } + + return backend, nil +} + func instantiate(ctx context.Context) (*Backend, error) { runtimeOnce.Do( func() { @@ -189,6 +207,103 @@ func (b *Backend) Save(ctx context.Context) ([]byte, error) { return output, nil } +// SaveNoCompress serializes the document without DEFLATE compression, mirroring +// AutoCommit::save_nocompress. +func (b *Backend) SaveNoCompress(ctx context.Context) ([]byte, error) { + if err := b.run(ctx, "am_save_nocompress"); err != nil { + return nil, fmt.Errorf("cannot save reference document: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read saved reference document: %w", err) + } + + return output, nil +} + +// SaveWithOptions serializes the document, optionally discarding orphan changes. +// Retaining orphans uses the default am_save; discarding them uses +// save_with_options(retain_orphans=false). +func (b *Backend) SaveWithOptions( + ctx context.Context, + retainOrphans bool, +) ([]byte, error) { + if retainOrphans { + return b.Save(ctx) + } + + if err := b.run(ctx, "am_save_no_orphans"); err != nil { + return nil, fmt.Errorf("cannot save reference document: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read saved reference document: %w", err) + } + + return output, nil +} + +func (b *Backend) SaveIncremental(ctx context.Context) ([]byte, error) { + if err := b.run(ctx, "am_save_incremental"); err != nil { + return nil, fmt.Errorf("cannot save incremental reference changes: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy incremental reference changes: %w", err) + } + + return output, nil +} + +func (b *Backend) LoadIncremental( + ctx context.Context, + data []byte, +) (uint64, error) { + pointer, length, err := b.write(ctx, data) + if err != nil { + return 0, fmt.Errorf("cannot write incremental reference changes: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_load_incremental", + uint64(pointer), + uint64(length), + ) + if err != nil { + return 0, fmt.Errorf("cannot load incremental reference changes: %w", err) + } + + applied := int64(result[0]) + if applied < 0 { + return 0, b.operationError(ctx, "cannot load incremental reference changes") + } + + return uint64(applied), nil +} + +// Isolate pins the document to the given heads, mirroring AutoCommit::isolate. +func (b *Backend) Isolate(ctx context.Context, heads [][32]byte) error { + if err := b.runBytes(ctx, "am_isolate", flattenHashes(heads)); err != nil { + return fmt.Errorf("cannot isolate reference document: %w", err) + } + + return nil +} + +// Integrate ends isolation, mirroring AutoCommit::integrate. +func (b *Backend) Integrate(ctx context.Context) error { + if err := b.run(ctx, "am_integrate"); err != nil { + return fmt.Errorf("cannot integrate reference document: %w", err) + } + + return nil +} + func (b *Backend) SetActor(ctx context.Context, actor []byte) error { if err := b.runBytes(ctx, "am_set_actor", actor); err != nil { return fmt.Errorf("cannot set reference actor: %w", err) @@ -225,125 +340,1001 @@ func (b *Backend) PutString(ctx context.Context, object Object, key, value strin return nil } -func (b *Backend) PutText(ctx context.Context, object Object, key string) (Object, error) { +func (b *Backend) GetString(ctx context.Context, object Object, key string) (string, error) { keyPointer, keyLength, err := b.write(ctx, []byte(key)) if err != nil { - return 0, fmt.Errorf("cannot write text key: %w", err) + return "", fmt.Errorf("cannot write map key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + if err := b.run( + ctx, + "am_get_string", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ); err != nil { + return "", fmt.Errorf("cannot get reference map value: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return "", fmt.Errorf("cannot copy reference map value: %w", err) + } + + return string(output), nil +} + +func (b *Backend) PutScalar( + ctx context.Context, + object Object, + key string, + value []byte, +) error { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return fmt.Errorf("cannot write scalar key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + valuePointer, valueLength, err := b.write(ctx, value) + if err != nil { + return fmt.Errorf("cannot write scalar value: %w", err) + } + defer b.free(ctx, valuePointer, valueLength) + + if err := b.run( + ctx, + "am_put_scalar", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + uint64(valuePointer), + uint64(valueLength), + ); err != nil { + return fmt.Errorf("cannot put reference scalar: %w", err) + } + + return nil +} + +func (b *Backend) GetScalar( + ctx context.Context, + object Object, + key string, +) ([]byte, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return nil, fmt.Errorf("cannot write scalar key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + if err := b.run( + ctx, + "am_get_scalar", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ); err != nil { + return nil, fmt.Errorf("cannot get reference scalar: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference scalar: %w", err) + } + + return output, nil +} + +func (b *Backend) GetScalarAtHeads( + ctx context.Context, + object Object, + key string, + heads [][32]byte, +) ([]byte, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return nil, fmt.Errorf("cannot write historical scalar key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + headPointer, headLength, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot write historical scalar heads: %w", err) + } + defer b.free(ctx, headPointer, headLength) + + if err := b.run( + ctx, + "am_get_scalar_at_heads", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + uint64(headPointer), + uint64(headLength), + ); err != nil { + return nil, fmt.Errorf("cannot get historical reference scalar: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy historical reference scalar: %w", err) + } + + return output, nil +} + +func (b *Backend) GetAllScalars( + ctx context.Context, + object Object, + key string, +) ([]byte, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return nil, fmt.Errorf("cannot write scalar key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + if err := b.run( + ctx, + "am_get_all_scalars", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ); err != nil { + return nil, fmt.Errorf("cannot get reference scalar conflicts: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference scalar conflicts: %w", err) + } + + return output, nil +} + +func (b *Backend) GetAllScalarsAt( + ctx context.Context, + object Object, + index uint64, +) ([]byte, error) { + if err := b.run( + ctx, + "am_get_all_scalars_at", + uint64(object), + index, + ); err != nil { + return nil, fmt.Errorf("cannot get reference sequence scalar conflicts: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference sequence scalar conflicts: %w", err) + } + + return output, nil +} + +func (b *Backend) PutObject( + ctx context.Context, + object Object, + key string, + objectType string, +) (Object, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return 0, fmt.Errorf("cannot write object key: %w", err) } defer b.free(ctx, keyPointer, keyLength) + typePointer, typeLength, err := b.write(ctx, []byte(objectType)) + if err != nil { + return 0, fmt.Errorf("cannot write object type: %w", err) + } + defer b.free(ctx, typePointer, typeLength) + result, err := b.call( ctx, - "am_put_text", + "am_put_object", uint64(object), uint64(keyPointer), uint64(keyLength), + uint64(typePointer), + uint64(typeLength), ) if err != nil { - return 0, fmt.Errorf("cannot create reference text: %w", err) + return 0, fmt.Errorf("cannot create reference object: %w", err) } handle := int64(result[0]) if handle < 0 { - return 0, b.operationError(ctx, "cannot create reference text") + return 0, b.operationError(ctx, "cannot create reference object") } return Object(handle), nil } -func (b *Backend) GetText(ctx context.Context, object Object, key string) (Object, error) { +func (b *Backend) GetObject( + ctx context.Context, + object Object, + key string, +) (Object, string, error) { keyPointer, keyLength, err := b.write(ctx, []byte(key)) if err != nil { - return 0, fmt.Errorf("cannot write text key: %w", err) + return 0, "", fmt.Errorf("cannot write object key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + result, err := b.call( + ctx, + "am_get_object", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ) + if err != nil { + return 0, "", fmt.Errorf("cannot get reference object: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, "", b.operationError(ctx, "cannot get reference object") + } + + rawType, err := b.output(ctx) + if err != nil { + return 0, "", fmt.Errorf("cannot copy reference object type: %w", err) + } + + return Object(handle), string(rawType), nil +} + +func (b *Backend) InsertScalar( + ctx context.Context, + object Object, + index uint64, + value []byte, +) error { + pointer, length, err := b.write(ctx, value) + if err != nil { + return fmt.Errorf("cannot write sequence scalar: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_insert_scalar", + uint64(object), + index, + uint64(pointer), + uint64(length), + ); err != nil { + return fmt.Errorf("cannot insert reference scalar: %w", err) + } + + return nil +} + +func (b *Backend) PutScalarAt( + ctx context.Context, + object Object, + index uint64, + value []byte, +) error { + pointer, length, err := b.write(ctx, value) + if err != nil { + return fmt.Errorf("cannot write sequence scalar: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_put_scalar_at", + uint64(object), + index, + uint64(pointer), + uint64(length), + ); err != nil { + return fmt.Errorf("cannot replace reference scalar: %w", err) + } + + return nil +} + +func (b *Backend) InsertObject( + ctx context.Context, + object Object, + index uint64, + objectType string, +) (Object, error) { + pointer, length, err := b.write(ctx, []byte(objectType)) + if err != nil { + return 0, fmt.Errorf("cannot write sequence object type: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_insert_object", + uint64(object), + index, + uint64(pointer), + uint64(length), + ) + if err != nil { + return 0, fmt.Errorf("cannot insert reference object: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot insert reference object") + } + + return Object(handle), nil +} + +func (b *Backend) PutObjectAt( + ctx context.Context, + object Object, + index uint64, + objectType string, +) (Object, error) { + pointer, length, err := b.write(ctx, []byte(objectType)) + if err != nil { + return 0, fmt.Errorf("cannot write replacement object type: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_put_object_at", + uint64(object), + index, + uint64(pointer), + uint64(length), + ) + if err != nil { + return 0, fmt.Errorf("cannot replace reference object: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot replace reference object") + } + + return Object(handle), nil +} + +func (b *Backend) GetScalarAt( + ctx context.Context, + object Object, + index uint64, +) ([]byte, error) { + if err := b.run( + ctx, + "am_get_scalar_at", + uint64(object), + index, + ); err != nil { + return nil, fmt.Errorf("cannot get reference sequence scalar: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference sequence scalar: %w", err) + } + + return output, nil +} + +func (b *Backend) GetObjectAt( + ctx context.Context, + object Object, + index uint64, +) (Object, string, error) { + result, err := b.call( + ctx, + "am_get_object_at", + uint64(object), + index, + ) + if err != nil { + return 0, "", fmt.Errorf("cannot get reference sequence object: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, "", b.operationError( + ctx, + "cannot get reference sequence object", + ) + } + + rawType, err := b.output(ctx) + if err != nil { + return 0, "", fmt.Errorf("cannot copy reference sequence object type: %w", err) + } + + return Object(handle), string(rawType), nil +} + +func (b *Backend) DeleteMap( + ctx context.Context, + object Object, + key string, +) error { + pointer, length, err := b.write(ctx, []byte(key)) + if err != nil { + return fmt.Errorf("cannot write deleted map key: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_delete_map", + uint64(object), + uint64(pointer), + uint64(length), + ); err != nil { + return fmt.Errorf("cannot delete reference map value: %w", err) + } + + return nil +} + +func (b *Backend) DeleteSequence( + ctx context.Context, + object Object, + index uint64, +) error { + if err := b.run( + ctx, + "am_delete_sequence", + uint64(object), + index, + ); err != nil { + return fmt.Errorf("cannot delete reference sequence value: %w", err) + } + + return nil +} + +func (b *Backend) Increment( + ctx context.Context, + object Object, + key string, + delta int64, +) error { + pointer, length, err := b.write(ctx, []byte(key)) + if err != nil { + return fmt.Errorf("cannot write incremented map key: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_increment", + uint64(object), + uint64(pointer), + uint64(length), + uint64(delta), + ); err != nil { + return fmt.Errorf("cannot increment reference counter: %w", err) + } + + return nil +} + +func (b *Backend) IncrementAt( + ctx context.Context, + object Object, + index uint64, + delta int64, +) error { + if err := b.run( + ctx, + "am_increment_at", + uint64(object), + index, + uint64(delta), + ); err != nil { + return fmt.Errorf("cannot increment reference sequence counter: %w", err) + } + + return nil +} + +func (b *Backend) Keys( + ctx context.Context, + object Object, +) ([]string, error) { + if err := b.run(ctx, "am_keys", uint64(object)); err != nil { + return nil, fmt.Errorf("cannot get reference map keys: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference map keys: %w", err) + } + + var keys []string + if err := json.Unmarshal(output, &keys); err != nil { + return nil, fmt.Errorf("cannot decode reference map keys: %w", err) + } + + return keys, nil +} + +func (b *Backend) Length( + ctx context.Context, + object Object, +) (uint64, error) { + result, err := b.call(ctx, "am_length", uint64(object)) + if err != nil { + return 0, fmt.Errorf("cannot get reference object length: %w", err) + } + + length := int64(result[0]) + if length < 0 { + return 0, b.operationError(ctx, "cannot get reference object length") + } + + return uint64(length), nil +} + +func (b *Backend) PutText(ctx context.Context, object Object, key string) (Object, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return 0, fmt.Errorf("cannot write text key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + result, err := b.call( + ctx, + "am_put_text", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ) + if err != nil { + return 0, fmt.Errorf("cannot create reference text: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot create reference text") + } + + return Object(handle), nil +} + +func (b *Backend) GetText(ctx context.Context, object Object, key string) (Object, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return 0, fmt.Errorf("cannot write text key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + result, err := b.call( + ctx, + "am_get_text", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + ) + if err != nil { + return 0, fmt.Errorf("cannot get reference text: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot get reference text") + } + + return Object(handle), nil +} + +func (b *Backend) SpliceText( + ctx context.Context, + object Object, + index uint32, + deleteCount int32, + value string, +) error { + valuePointer, valueLength, err := b.write(ctx, []byte(value)) + if err != nil { + return fmt.Errorf("cannot write splice value: %w", err) + } + defer b.free(ctx, valuePointer, valueLength) + + if err := b.run( + ctx, + "am_text_splice", + uint64(object), + uint64(index), + uint64(uint32(deleteCount)), + uint64(valuePointer), + uint64(valueLength), + ); err != nil { + return fmt.Errorf("cannot splice reference text: %w", err) + } + + return nil +} + +func (b *Backend) UpdateText( + ctx context.Context, + object Object, + value string, +) error { + valuePointer, valueLength, err := b.write(ctx, []byte(value)) + if err != nil { + return fmt.Errorf("cannot write update value: %w", err) + } + defer b.free(ctx, valuePointer, valueLength) + + if err := b.run( + ctx, + "am_text_update", + uint64(object), + uint64(valuePointer), + uint64(valueLength), + ); err != nil { + return fmt.Errorf("cannot update reference text: %w", err) + } + + return nil +} + +func (b *Backend) UpdateSpans( + ctx context.Context, + object Object, + spans []byte, + config []byte, +) error { + spansPointer, spansLength, err := b.write(ctx, spans) + if err != nil { + return fmt.Errorf("cannot write update spans: %w", err) + } + defer b.free(ctx, spansPointer, spansLength) + + configPointer, configLength, err := b.write(ctx, config) + if err != nil { + return fmt.Errorf("cannot write update spans config: %w", err) + } + defer b.free(ctx, configPointer, configLength) + + if err := b.run( + ctx, + "am_update_spans", + uint64(object), + uint64(spansPointer), + uint64(spansLength), + uint64(configPointer), + uint64(configLength), + ); err != nil { + return fmt.Errorf("cannot update reference spans: %w", err) + } + + return nil +} + +func (b *Backend) MarkText( + ctx context.Context, + object Object, + start uint32, + end uint32, + name string, + value []byte, + expand string, +) error { + namePointer, nameLength, err := b.write(ctx, []byte(name)) + if err != nil { + return fmt.Errorf("cannot write mark name: %w", err) + } + defer b.free(ctx, namePointer, nameLength) + + valuePointer, valueLength, err := b.write(ctx, value) + if err != nil { + return fmt.Errorf("cannot write mark value: %w", err) + } + defer b.free(ctx, valuePointer, valueLength) + + expandPointer, expandLength, err := b.write(ctx, []byte(expand)) + if err != nil { + return fmt.Errorf("cannot write mark expansion: %w", err) + } + defer b.free(ctx, expandPointer, expandLength) + + if err := b.run( + ctx, + "am_text_mark", + uint64(object), + uint64(start), + uint64(end), + uint64(namePointer), + uint64(nameLength), + uint64(valuePointer), + uint64(valueLength), + uint64(expandPointer), + uint64(expandLength), + ); err != nil { + return fmt.Errorf("cannot mark reference text: %w", err) + } + + return nil +} + +func (b *Backend) SplitBlock( + ctx context.Context, + object Object, + index uint32, +) (Object, error) { + result, err := b.call( + ctx, + "am_split_block", + uint64(object), + uint64(index), + ) + if err != nil { + return 0, fmt.Errorf("cannot split reference block: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot split reference block") + } + + return Object(handle), nil +} + +func (b *Backend) JoinBlock( + ctx context.Context, + object Object, + index uint32, +) error { + if err := b.run( + ctx, + "am_join_block", + uint64(object), + uint64(index), + ); err != nil { + return fmt.Errorf("cannot join reference block: %w", err) + } + + return nil +} + +func (b *Backend) ReplaceBlock( + ctx context.Context, + object Object, + index uint32, +) (Object, error) { + result, err := b.call( + ctx, + "am_replace_block", + uint64(object), + uint64(index), + ) + if err != nil { + return 0, fmt.Errorf("cannot replace reference block: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot replace reference block") + } + + return Object(handle), nil +} + +func (b *Backend) Text(ctx context.Context, object Object) (string, error) { + if err := b.run(ctx, "am_text", uint64(object)); err != nil { + return "", fmt.Errorf("cannot read reference text: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return "", fmt.Errorf("cannot copy reference text: %w", err) + } + + return string(output), nil +} + +func (b *Backend) TextAt( + ctx context.Context, + object Object, + heads [][32]byte, +) (string, error) { + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return "", fmt.Errorf("cannot write historical text heads: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_text_at", + uint64(object), + uint64(pointer), + uint64(length), + ); err != nil { + return "", fmt.Errorf("cannot read historical reference text: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return "", fmt.Errorf("cannot copy historical reference text: %w", err) + } + + return string(output), nil +} + +func (b *Backend) TextSpans(ctx context.Context, object Object) ([]byte, error) { + if err := b.run(ctx, "am_text_spans", uint64(object)); err != nil { + return nil, fmt.Errorf("cannot read reference text spans: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference text spans: %w", err) + } + + return output, nil +} + +func (b *Backend) TextSpansAt( + ctx context.Context, + object Object, + heads [][32]byte, +) ([]byte, error) { + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot write historical span heads: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_text_spans_at", + uint64(object), + uint64(pointer), + uint64(length), + ); err != nil { + return nil, fmt.Errorf("cannot read historical reference text spans: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy historical reference text spans: %w", err) + } + + return output, nil +} + +func (b *Backend) Marks(ctx context.Context, object Object) ([]byte, error) { + if err := b.run(ctx, "am_marks", uint64(object)); err != nil { + return nil, fmt.Errorf("cannot read reference marks: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference marks: %w", err) + } + + return output, nil +} + +func (b *Backend) MarksAt( + ctx context.Context, + object Object, + heads [][32]byte, +) ([]byte, error) { + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot write historical mark heads: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_marks_at", + uint64(object), + uint64(pointer), + uint64(length), + ); err != nil { + return nil, fmt.Errorf("cannot read historical reference marks: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy historical reference marks: %w", err) } - defer b.free(ctx, keyPointer, keyLength) - result, err := b.call( + return output, nil +} + +func (b *Backend) TextCursor(ctx context.Context, object Object, index uint32) ([]byte, error) { + if err := b.run( ctx, - "am_get_text", + "am_text_cursor", uint64(object), - uint64(keyPointer), - uint64(keyLength), - ) - if err != nil { - return 0, fmt.Errorf("cannot get reference text: %w", err) + uint64(index), + ); err != nil { + return nil, fmt.Errorf("cannot create reference text cursor: %w", err) } - handle := int64(result[0]) - if handle < 0 { - return 0, b.operationError(ctx, "cannot get reference text") + cursor, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference text cursor: %w", err) } - return Object(handle), nil + return cursor, nil } -func (b *Backend) SpliceText( +func (b *Backend) TextCursorMoving( ctx context.Context, object Object, index uint32, - deleteCount int32, - value string, -) error { - valuePointer, valueLength, err := b.write(ctx, []byte(value)) - if err != nil { - return fmt.Errorf("cannot write splice value: %w", err) + moveBefore bool, +) ([]byte, error) { + var movement uint64 + if moveBefore { + movement = 1 } - defer b.free(ctx, valuePointer, valueLength) if err := b.run( ctx, - "am_text_splice", + "am_text_cursor_moving", uint64(object), uint64(index), - uint64(uint32(deleteCount)), - uint64(valuePointer), - uint64(valueLength), + movement, ); err != nil { - return fmt.Errorf("cannot splice reference text: %w", err) - } - - return nil -} - -func (b *Backend) Text(ctx context.Context, object Object) (string, error) { - if err := b.run(ctx, "am_text", uint64(object)); err != nil { - return "", fmt.Errorf("cannot read reference text: %w", err) + return nil, fmt.Errorf("cannot create moving reference text cursor: %w", err) } - output, err := b.output(ctx) + cursor, err := b.output(ctx) if err != nil { - return "", fmt.Errorf("cannot copy reference text: %w", err) + return nil, fmt.Errorf("cannot copy moving reference text cursor: %w", err) } - return string(output), nil + return cursor, nil } -func (b *Backend) TextSpans(ctx context.Context, object Object) ([]byte, error) { - if err := b.run(ctx, "am_text_spans", uint64(object)); err != nil { - return nil, fmt.Errorf("cannot read reference text spans: %w", err) +func (b *Backend) TextCursorMovingAt( + ctx context.Context, + object Object, + index uint32, + moveBefore bool, + heads [][32]byte, +) ([]byte, error) { + var movement uint64 + if moveBefore { + movement = 1 } - output, err := b.output(ctx) + pointer, length, err := b.write(ctx, flattenHashes(heads)) if err != nil { - return nil, fmt.Errorf("cannot copy reference text spans: %w", err) + return nil, fmt.Errorf("cannot write cursor heads: %w", err) } + defer b.free(ctx, pointer, length) - return output, nil -} - -func (b *Backend) TextCursor(ctx context.Context, object Object, index uint32) ([]byte, error) { if err := b.run( ctx, - "am_text_cursor", + "am_text_cursor_moving_at", uint64(object), uint64(index), + movement, + uint64(pointer), + uint64(length), ); err != nil { - return nil, fmt.Errorf("cannot create reference text cursor: %w", err) + return nil, fmt.Errorf("cannot create historical reference text cursor: %w", err) } cursor, err := b.output(ctx) if err != nil { - return nil, fmt.Errorf("cannot copy reference text cursor: %w", err) + return nil, fmt.Errorf("cannot copy historical reference text cursor: %w", err) } return cursor, nil @@ -390,6 +1381,11 @@ func (b *Backend) Commit( ) ([32]byte, error) { var hash [32]byte + timestampSeconds := timestamp.Unix() + if timestamp.IsZero() { + timestampSeconds = 0 + } + messagePointer, messageLength, err := b.write(ctx, []byte(message)) if err != nil { return hash, fmt.Errorf("cannot write commit message: %w", err) @@ -401,7 +1397,7 @@ func (b *Backend) Commit( "am_commit", uint64(messagePointer), uint64(messageLength), - uint64(timestamp.Unix()), + uint64(timestampSeconds), ); err != nil { return hash, fmt.Errorf("cannot commit reference document: %w", err) } @@ -420,6 +1416,145 @@ func (b *Backend) Commit( return hash, nil } +func (b *Backend) EmptyCommit( + ctx context.Context, + message string, + timestamp time.Time, +) ([32]byte, error) { + var hash [32]byte + + timestampSeconds := timestamp.Unix() + if timestamp.IsZero() { + timestampSeconds = 0 + } + + messagePointer, messageLength, err := b.write(ctx, []byte(message)) + if err != nil { + return hash, fmt.Errorf("cannot write empty commit message: %w", err) + } + defer b.free(ctx, messagePointer, messageLength) + + if err := b.run( + ctx, + "am_empty_commit", + uint64(messagePointer), + uint64(messageLength), + uint64(timestampSeconds), + ); err != nil { + return hash, fmt.Errorf("cannot commit empty reference change: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return hash, fmt.Errorf("cannot copy empty reference change hash: %w", err) + } + + if len(output) != len(hash) { + return hash, fmt.Errorf("invalid empty reference change hash length %d", len(output)) + } + + copy(hash[:], output) + + return hash, nil +} + +func (b *Backend) Rollback(ctx context.Context) (uint64, error) { + result, err := b.call(ctx, "am_rollback") + if err != nil { + return 0, fmt.Errorf("cannot roll back reference document: %w", err) + } + + cancelled := int64(result[0]) + if cancelled < 0 { + return 0, b.operationError(ctx, "cannot roll back reference document") + } + + return uint64(cancelled), nil +} + +func (b *Backend) Stats(ctx context.Context) ([]byte, error) { + if err := b.run(ctx, "am_stats"); err != nil { + return nil, fmt.Errorf("cannot read reference stats: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference stats: %w", err) + } + + return output, nil +} + +func (b *Backend) CurrentState(ctx context.Context) ([]byte, error) { + if err := b.run(ctx, "am_current_state"); err != nil { + return nil, fmt.Errorf("cannot read reference current state: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference current state: %w", err) + } + + return output, nil +} + +func (b *Backend) UpdateDiffCursor(ctx context.Context) error { + if err := b.run(ctx, "am_update_diff_cursor"); err != nil { + return fmt.Errorf("cannot update reference diff cursor: %w", err) + } + + return nil +} + +func (b *Backend) DiffIncremental(ctx context.Context) ([]byte, error) { + if err := b.run(ctx, "am_diff_incremental"); err != nil { + return nil, fmt.Errorf("cannot read reference incremental diff: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference incremental diff: %w", err) + } + + return output, nil +} + +func (b *Backend) Diff( + ctx context.Context, + before [][32]byte, + after [][32]byte, +) ([]byte, error) { + beforePointer, beforeLength, err := b.write(ctx, flattenHashes(before)) + if err != nil { + return nil, fmt.Errorf("cannot write diff before heads: %w", err) + } + defer b.free(ctx, beforePointer, beforeLength) + + afterPointer, afterLength, err := b.write(ctx, flattenHashes(after)) + if err != nil { + return nil, fmt.Errorf("cannot write diff after heads: %w", err) + } + defer b.free(ctx, afterPointer, afterLength) + + if err := b.run( + ctx, + "am_diff", + uint64(beforePointer), + uint64(beforeLength), + uint64(afterPointer), + uint64(afterLength), + ); err != nil { + return nil, fmt.Errorf("cannot read reference diff: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference diff: %w", err) + } + + return output, nil +} + func (b *Backend) Heads(ctx context.Context) ([][32]byte, error) { if err := b.run(ctx, "am_heads"); err != nil { return nil, fmt.Errorf("cannot read reference heads: %w", err) @@ -442,6 +1577,110 @@ func (b *Backend) Heads(ctx context.Context) ([][32]byte, error) { return heads, nil } +func (b *Backend) HasHeads( + ctx context.Context, + heads [][32]byte, +) (bool, error) { + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return false, fmt.Errorf("cannot write reference heads: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_has_heads", + uint64(pointer), + uint64(length), + ) + if err != nil { + return false, fmt.Errorf("cannot inspect reference heads: %w", err) + } + + value := int32(result[0]) + if value < 0 { + return false, b.operationError(ctx, "cannot inspect reference heads") + } + + return value != 0, nil +} + +// BloomContains builds a sync Bloom filter from the seed change hashes and +// reports whether it contains the target hash. Because Bloom filters admit +// false positives, a true result does not guarantee membership; parity tests +// use this to reproduce the upstream false-positive search deterministically. +func (b *Backend) BloomContains( + ctx context.Context, + target [32]byte, + seeds [][32]byte, +) (bool, error) { + input := make([]byte, 0, (len(seeds)+1)*32) + input = append(input, target[:]...) + input = append(input, flattenHashes(seeds)...) + + pointer, length, err := b.write(ctx, input) + if err != nil { + return false, fmt.Errorf("cannot write bloom hashes: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_bloom_contains", + uint64(pointer), + uint64(length), + ) + if err != nil { + return false, fmt.Errorf("cannot evaluate reference bloom filter: %w", err) + } + + value := int32(result[0]) + if value < 0 { + return false, b.operationError(ctx, "cannot evaluate reference bloom filter") + } + + return value != 0, nil +} + +func (b *Backend) MissingDependencies( + ctx context.Context, + heads [][32]byte, +) ([][32]byte, error) { + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot write dependency heads: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_missing_dependencies", + uint64(pointer), + uint64(length), + ); err != nil { + return nil, fmt.Errorf("cannot get reference missing dependencies: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference missing dependencies: %w", err) + } + + if len(output)%32 != 0 { + return nil, fmt.Errorf( + "invalid reference dependency byte length %d", + len(output), + ) + } + + result := make([][32]byte, len(output)/32) + for i := range result { + copy(result[i][:], output[i*32:(i+1)*32]) + } + + return result, nil +} + func (b *Backend) Merge(ctx context.Context, other []byte) ([][32]byte, error) { if err := b.runBytes(ctx, "am_merge", other); err != nil { return nil, fmt.Errorf("cannot merge reference document: %w", err) @@ -486,6 +1725,52 @@ func (b *Backend) CloseSyncState(ctx context.Context, handle uint32) error { return nil } +func (b *Backend) SetSyncReadOnly( + ctx context.Context, + handle uint32, + readOnly bool, +) error { + var value uint64 + if readOnly { + value = 1 + } + + if err := b.run( + ctx, + "am_sync_set_read_only", + uint64(handle), + value, + ); err != nil { + return fmt.Errorf("cannot set reference sync read-only mode: %w", err) + } + + return nil +} + +func (b *Backend) SyncPeerReadOnly( + ctx context.Context, + handle uint32, +) (bool, error) { + result, err := b.call( + ctx, + "am_sync_peer_read_only", + uint64(handle), + ) + if err != nil { + return false, fmt.Errorf("cannot get reference peer read-only mode: %w", err) + } + + value := int32(result[0]) + if value < 0 { + return false, b.operationError( + ctx, + "cannot get reference peer read-only mode", + ) + } + + return value != 0, nil +} + func (b *Backend) GenerateSyncMessage( ctx context.Context, handle uint32, @@ -703,3 +1988,12 @@ func (b *Backend) read(pointer, length uint32) ([]byte, error) { return append([]byte(nil), value...), nil } + +func flattenHashes(hashes [][32]byte) []byte { + value := make([]byte, 0, len(hashes)*32) + for _, hash := range hashes { + value = append(value, hash[:]...) + } + + return value +} diff --git a/pkg/automerge/internal/reference/reference.wasm b/pkg/automerge/internal/reference/reference.wasm index 8103a952a2..18dd8e919a 100755 Binary files a/pkg/automerge/internal/reference/reference.wasm and b/pkg/automerge/internal/reference/reference.wasm differ diff --git a/pkg/automerge/internal/reference/reference.wasm.sha256 b/pkg/automerge/internal/reference/reference.wasm.sha256 index 162f05e65b..d8fb66577a 100644 --- a/pkg/automerge/internal/reference/reference.wasm.sha256 +++ b/pkg/automerge/internal/reference/reference.wasm.sha256 @@ -1 +1 @@ -45f1556553f5a265141adc4565079b68fa1b6aa75c27b29bcc0d309feba1fa96 reference.wasm +a2d9e799d554ea07396c3f4f56e31aba367474418485e92a9bf31a77bb93c74e reference.wasm diff --git a/pkg/automerge/internal/reference/wasm/Cargo.lock b/pkg/automerge/internal/reference/wasm/Cargo.lock index a3f3349362..86c5699a6b 100644 --- a/pkg/automerge/internal/reference/wasm/Cargo.lock +++ b/pkg/automerge/internal/reference/wasm/Cargo.lock @@ -237,6 +237,7 @@ name = "probo-automerge-reference" version = "0.1.0" dependencies = [ "automerge", + "hex", "serde_json", ] diff --git a/pkg/automerge/internal/reference/wasm/Cargo.toml b/pkg/automerge/internal/reference/wasm/Cargo.toml index 14bce320d2..9eb32d397f 100644 --- a/pkg/automerge/internal/reference/wasm/Cargo.toml +++ b/pkg/automerge/internal/reference/wasm/Cargo.toml @@ -30,6 +30,7 @@ crate-type = ["cdylib"] [dependencies] automerge = { version = "=0.10.0", features = ["utf16-indexing"] } +hex = "0.4.3" serde_json = "1" [profile.release] diff --git a/pkg/automerge/internal/reference/wasm/src/lib.rs b/pkg/automerge/internal/reference/wasm/src/lib.rs index ea67238ed4..45093abaf8 100644 --- a/pkg/automerge/internal/reference/wasm/src/lib.rs +++ b/pkg/automerge/internal/reference/wasm/src/lib.rs @@ -23,9 +23,14 @@ use std::mem; use std::ptr; use std::slice; +use automerge::iter::Span; +use automerge::marks::{ExpandMark, Mark, MarkSet, UpdateSpansConfig}; use automerge::sync::{Message, State as SyncState, SyncDoc}; use automerge::transaction::{CommitOptions, Transactable}; -use automerge::{ActorId, AutoCommit, Cursor, ObjId, ObjType, ReadDoc, Value, ROOT}; +use automerge::{ + ActorId, AutoCommit, ChangeHash, Cursor, MoveCursor, ObjId, ObjType, Patch, PatchAction, Prop, + ReadDoc, ScalarValue, Value, ROOT, +}; struct State { doc: AutoCommit, @@ -98,6 +103,141 @@ fn input_string(pointer: u32, length: u32) -> Result { String::from_utf8(input_bytes(pointer, length)).map_err(|error| error.to_string()) } +fn input_scalar(pointer: u32, length: u32) -> Result { + let value: serde_json::Value = + serde_json::from_slice(&input_bytes(pointer, length)).map_err(|error| error.to_string())?; + scalar_from_value(&value) +} + +fn scalar_from_value(value: &serde_json::Value) -> Result { + let scalar_type = value + .get("type") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "scalar type is missing".to_owned())?; + + match scalar_type { + "null" => Ok(ScalarValue::Null), + "boolean" => value + .get("bool") + .and_then(serde_json::Value::as_bool) + .map(ScalarValue::Boolean) + .ok_or_else(|| "scalar boolean is missing".to_owned()), + "uint" => value + .get("uint") + .and_then(serde_json::Value::as_u64) + .map(ScalarValue::Uint) + .ok_or_else(|| "scalar uint is missing".to_owned()), + "int" => value + .get("int") + .and_then(serde_json::Value::as_i64) + .map(ScalarValue::Int) + .ok_or_else(|| "scalar int is missing".to_owned()), + "float64" => value + .get("floatBits") + .and_then(serde_json::Value::as_u64) + .map(|bits| ScalarValue::F64(f64::from_bits(bits))) + .ok_or_else(|| "scalar float bits are missing".to_owned()), + "string" => value + .get("string") + .and_then(serde_json::Value::as_str) + .map(|value| ScalarValue::Str(value.into())) + .ok_or_else(|| "scalar string is missing".to_owned()), + "bytes" => value + .get("bytes") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "scalar bytes are missing".to_owned()) + .and_then(|value| hex::decode(value).map_err(|error| error.to_string())) + .map(ScalarValue::Bytes), + "counter" => value + .get("int") + .and_then(serde_json::Value::as_i64) + .map(|value| ScalarValue::Counter(value.into())) + .ok_or_else(|| "scalar counter is missing".to_owned()), + "timestamp" => value + .get("int") + .and_then(serde_json::Value::as_i64) + .map(ScalarValue::Timestamp) + .ok_or_else(|| "scalar timestamp is missing".to_owned()), + other => Err(format!("unknown scalar type {other:?}")), + } +} + +fn input_heads(pointer: u32, length: u32) -> Result, String> { + let bytes = input_bytes(pointer, length); + if bytes.len() % 32 != 0 { + return Err("head bytes are not a multiple of 32".to_owned()); + } + + bytes + .chunks_exact(32) + .map(|value| { + let mut hash = [0_u8; 32]; + hash.copy_from_slice(value); + Ok(ChangeHash(hash)) + }) + .collect() +} + +fn scalar_json(value: &ScalarValue) -> Result { + let value = match value { + ScalarValue::Null => serde_json::json!({"type": "null"}), + ScalarValue::Boolean(value) => { + serde_json::json!({"type": "boolean", "bool": value}) + } + ScalarValue::Uint(value) => serde_json::json!({"type": "uint", "uint": value}), + ScalarValue::Int(value) => serde_json::json!({"type": "int", "int": value}), + ScalarValue::F64(value) => { + serde_json::json!({"type": "float64", "floatBits": value.to_bits()}) + } + ScalarValue::Str(value) => serde_json::json!({"type": "string", "string": value}), + ScalarValue::Bytes(value) => { + serde_json::json!({"type": "bytes", "bytes": hex::encode(value)}) + } + ScalarValue::Counter(value) => { + serde_json::json!({"type": "counter", "int": i64::from(value)}) + } + ScalarValue::Timestamp(value) => { + serde_json::json!({"type": "timestamp", "int": value}) + } + ScalarValue::Unknown { .. } => return Err("unknown scalar type is unsupported".to_owned()), + }; + + Ok(value) +} + +fn encode_scalar(value: &ScalarValue) -> Result, String> { + serde_json::to_vec(&scalar_json(value)?).map_err(|error| error.to_string()) +} + +fn parse_object_type(value: &str) -> Result { + match value { + "map" => Ok(ObjType::Map), + "list" => Ok(ObjType::List), + "text" => Ok(ObjType::Text), + "table" => Ok(ObjType::Table), + other => Err(format!("unknown object type {other:?}")), + } +} + +fn encode_object_type(value: ObjType) -> &'static str { + match value { + ObjType::Map => "map", + ObjType::List => "list", + ObjType::Text => "text", + ObjType::Table => "table", + } +} + +fn parse_mark_expand(value: &str) -> Result { + match value { + "before" => Ok(ExpandMark::Before), + "after" => Ok(ExpandMark::After), + "both" => Ok(ExpandMark::Both), + "none" => Ok(ExpandMark::None), + other => Err(format!("unknown mark expansion {other:?}")), + } +} + #[no_mangle] pub extern "C" fn am_alloc(length: u32) -> u32 { if length == 0 { @@ -186,6 +326,23 @@ pub extern "C" fn am_load(pointer: u32, length: u32) -> i32 { }) } +#[no_mangle] +pub extern "C" fn am_load_convert_strings(pointer: u32, length: u32) -> i32 { + let bytes = input_bytes(pointer, length); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let options = automerge::LoadOptions::new() + .migrate_strings(automerge::StringMigration::ConvertToText); + match AutoCommit::load_with_options(&bytes, options) { + Ok(doc) => { + state.reset(doc); + 0 + } + Err(error) => state.fail(error), + } + }) +} + #[no_mangle] pub extern "C" fn am_save() -> i32 { STATE.with(|state| { @@ -196,6 +353,69 @@ pub extern "C" fn am_save() -> i32 { 0 } +// am_save_nocompress serializes the document without DEFLATE-compressing the RLE +// columns, mirroring AutoCommit::save_nocompress. +#[no_mangle] +pub extern "C" fn am_save_nocompress() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.output = state.doc.save_nocompress(); + state.error.clear(); + }); + 0 +} + +// am_save_no_orphans serializes the document while discarding orphan changes +// (changes whose dependencies are missing), mirroring SaveOptions.retain_orphans +// set to false. The default am_save retains orphans. +#[no_mangle] +pub extern "C" fn am_save_no_orphans() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let options = automerge::SaveOptions { + deflate: false, + retain_orphans: false, + }; + state.output = state.doc.save_with_options(options); + state.error.clear(); + }); + 0 +} + +#[no_mangle] +pub extern "C" fn am_save_incremental() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.output = state.doc.save_incremental(); + state.error.clear(); + }); + 0 +} + +#[no_mangle] +pub extern "C" fn am_load_incremental(pointer: u32, length: u32) -> i64 { + let bytes = input_bytes(pointer, length); + STATE.with(|state| { + let mut state = state.borrow_mut(); + match state.doc.load_incremental(&bytes) { + Ok(applied) => match i64::try_from(applied) { + Ok(applied) => { + state.error.clear(); + applied + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + #[no_mangle] pub extern "C" fn am_set_actor(pointer: u32, length: u32) -> i32 { let actor = input_bytes(pointer, length); @@ -245,7 +465,224 @@ pub extern "C" fn am_put_string( } #[no_mangle] -pub extern "C" fn am_put_text(object_handle: u32, key_pointer: u32, key_length: u32) -> i64 { +pub extern "C" fn am_get_string(object_handle: u32, key_pointer: u32, key_length: u32) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.get(&object, key) { + Ok(Some((Value::Scalar(value), _))) => match value.as_ref() { + ScalarValue::Str(value) => { + state.output = value.as_bytes().to_vec(); + state.error.clear(); + 0 + } + _ => state.fail("value is not a string"), + }, + Ok(Some(_)) => state.fail("value is not a scalar"), + Ok(None) => state.fail("value does not exist"), + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_put_scalar( + object_handle: u32, + key_pointer: u32, + key_length: u32, + value_pointer: u32, + value_length: u32, +) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let value = match input_scalar(value_pointer, value_length) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.put(&object, key, value) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_scalar(object_handle: u32, key_pointer: u32, key_length: u32) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.get(&object, key) { + Ok(Some((Value::Scalar(value), _))) => match encode_scalar(value.as_ref()) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + }, + Ok(Some(_)) => state.fail("value is not a scalar"), + Ok(None) => state.fail("value does not exist"), + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_scalar_at_heads( + object_handle: u32, + key_pointer: u32, + key_length: u32, + heads_pointer: u32, + heads_length: u32, +) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let heads = match input_heads(heads_pointer, heads_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.get_at(&object, key, &heads) { + Ok(Some((Value::Scalar(value), _))) => match encode_scalar(value.as_ref()) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + }, + Ok(Some(_)) => state.fail("historical value is not a scalar"), + Ok(None) => state.fail("historical value does not exist"), + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_all_scalars(object_handle: u32, key_pointer: u32, key_length: u32) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.get_all(&object, key) { + Ok(values) => { + if values.is_empty() { + return state.fail("scalar property does not exist"); + } + let encoded = values + .iter() + .filter_map(|(value, _)| match value { + Value::Scalar(value) => Some(scalar_json(value.as_ref())), + Value::Object(_) => None, + }) + .collect::, _>>(); + match encoded.and_then(|values| { + serde_json::to_vec(&values).map_err(|error| error.to_string()) + }) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_all_scalars_at(object_handle: u32, index: u64) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.get_all(&object, index) { + Ok(values) => { + if values.is_empty() { + return state.fail("sequence value does not exist"); + } + let encoded = values + .iter() + .filter_map(|(value, _)| match value { + Value::Scalar(value) => Some(scalar_json(value.as_ref())), + Value::Object(_) => None, + }) + .collect::, _>>(); + match encoded.and_then(|values| { + serde_json::to_vec(&values).map_err(|error| error.to_string()) + }) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_put_object( + object_handle: u32, + key_pointer: u32, + key_length: u32, + type_pointer: u32, + type_length: u32, +) -> i64 { let key = match input_string(key_pointer, key_length) { Ok(key) => key, Err(error) => { @@ -253,6 +690,14 @@ pub extern "C" fn am_put_text(object_handle: u32, key_pointer: u32, key_length: return -1; } }; + let object_type = + match input_string(type_pointer, type_length).and_then(|value| parse_object_type(&value)) { + Ok(object_type) => object_type, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; STATE.with(|state| { let mut state = state.borrow_mut(); @@ -263,8 +708,8 @@ pub extern "C" fn am_put_text(object_handle: u32, key_pointer: u32, key_length: return -1; } }; - match state.doc.put_object(&object, key, ObjType::Text) { - Ok(text) => match state.push_object(text) { + match state.doc.put_object(&object, key, object_type) { + Ok(object) => match state.push_object(object) { Ok(handle) => { state.error.clear(); i64::from(handle) @@ -283,7 +728,7 @@ pub extern "C" fn am_put_text(object_handle: u32, key_pointer: u32, key_length: } #[no_mangle] -pub extern "C" fn am_get_text(object_handle: u32, key_pointer: u32, key_length: u32) -> i64 { +pub extern "C" fn am_get_object(object_handle: u32, key_pointer: u32, key_length: u32) -> i64 { let key = match input_string(key_pointer, key_length) { Ok(key) => key, Err(error) => { @@ -302,8 +747,9 @@ pub extern "C" fn am_get_text(object_handle: u32, key_pointer: u32, key_length: } }; match state.doc.get(&object, key) { - Ok(Some((Value::Object(ObjType::Text), text))) => match state.push_object(text) { + Ok(Some((Value::Object(object_type), object))) => match state.push_object(object) { Ok(handle) => { + state.output = encode_object_type(object_type).as_bytes().to_vec(); state.error.clear(); i64::from(handle) } @@ -313,11 +759,11 @@ pub extern "C" fn am_get_text(object_handle: u32, key_pointer: u32, key_length: } }, Ok(Some(_)) => { - state.fail("value is not text"); + state.fail("value is not an object"); -1 } Ok(None) => { - state.fail("text does not exist"); + state.fail("object does not exist"); -1 } Err(error) => { @@ -329,14 +775,13 @@ pub extern "C" fn am_get_text(object_handle: u32, key_pointer: u32, key_length: } #[no_mangle] -pub extern "C" fn am_text_splice( +pub extern "C" fn am_insert_scalar( object_handle: u32, - index: u32, - delete_count: i32, + index: u64, value_pointer: u32, value_length: u32, ) -> i32 { - let value = match input_string(value_pointer, value_length) { + let value = match input_scalar(value_pointer, value_length) { Ok(value) => value, Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), }; @@ -347,10 +792,11 @@ pub extern "C" fn am_text_splice( Ok(object) => object, Err(error) => return state.fail(error), }; - match state - .doc - .splice_text(&object, index as usize, delete_count as isize, &value) - { + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.insert(&object, index, value) { Ok(()) => { state.error.clear(); 0 @@ -361,16 +807,961 @@ pub extern "C" fn am_text_splice( } #[no_mangle] -pub extern "C" fn am_text(object_handle: u32) -> i32 { - STATE.with(|state| { - let mut state = state.borrow_mut(); - let object = match state.object(object_handle) { - Ok(object) => object, +pub extern "C" fn am_put_scalar_at( + object_handle: u32, + index: u64, + value_pointer: u32, + value_length: u32, +) -> i32 { + let value = match input_scalar(value_pointer, value_length) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.put(&object, index, value) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_insert_object( + object_handle: u32, + index: u64, + type_pointer: u32, + type_length: u32, +) -> i64 { + let object_type = + match input_string(type_pointer, type_length).and_then(|value| parse_object_type(&value)) { + Ok(object_type) => object_type, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => { + state.fail("sequence index exceeds platform capacity"); + return -1; + } + }; + match state.doc.insert_object(&object, index, object_type) { + Ok(object) => match state.push_object(object) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_put_object_at( + object_handle: u32, + index: u64, + type_pointer: u32, + type_length: u32, +) -> i64 { + let object_type = + match input_string(type_pointer, type_length).and_then(|value| parse_object_type(&value)) { + Ok(object_type) => object_type, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => { + state.fail("sequence index exceeds platform capacity"); + return -1; + } + }; + match state.doc.put_object(&object, index, object_type) { + Ok(object) => match state.push_object(object) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_scalar_at(object_handle: u32, index: u64) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.get(&object, index) { + Ok(Some((Value::Scalar(value), _))) => match encode_scalar(value.as_ref()) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + }, + Ok(Some(_)) => state.fail("value is not a scalar"), + Ok(None) => state.fail("sequence value does not exist"), + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_object_at(object_handle: u32, index: u64) -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => { + state.fail("sequence index exceeds platform capacity"); + return -1; + } + }; + match state.doc.get(&object, index) { + Ok(Some((Value::Object(object_type), object))) => match state.push_object(object) { + Ok(handle) => { + state.output = encode_object_type(object_type).as_bytes().to_vec(); + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Ok(Some(_)) => { + state.fail("value is not an object"); + -1 + } + Ok(None) => { + state.fail("sequence object does not exist"); + -1 + } + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_delete_map(object_handle: u32, key_pointer: u32, key_length: u32) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.delete(&object, key) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_delete_sequence(object_handle: u32, index: u64) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.delete(&object, index) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_increment( + object_handle: u32, + key_pointer: u32, + key_length: u32, + delta: i64, +) -> i32 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.increment(&object, key, delta) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_increment_at(object_handle: u32, index: u64, delta: i64) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let index = match usize::try_from(index) { + Ok(index) => index, + Err(_) => return state.fail("sequence index exceeds platform capacity"), + }; + match state.doc.increment(&object, index, delta) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_keys(object_handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let mut keys = state.doc.keys(&object).collect::>(); + keys.sort(); + match serde_json::to_vec(&keys) { + Ok(encoded) => { + state.output = encoded; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_length(object_handle: u32) -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match i64::try_from(state.doc.length(&object)) { + Ok(length) => { + state.error.clear(); + length + } + Err(_) => { + state.fail("object length exceeds i64"); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_put_text(object_handle: u32, key_pointer: u32, key_length: u32) -> i64 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match state.doc.put_object(&object, key, ObjType::Text) { + Ok(text) => match state.push_object(text) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_get_text(object_handle: u32, key_pointer: u32, key_length: u32) -> i64 { + let key = match input_string(key_pointer, key_length) { + Ok(key) => key, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match state.doc.get(&object, key) { + Ok(Some((Value::Object(ObjType::Text), text))) => match state.push_object(text) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Ok(Some(_)) => { + state.fail("value is not text"); + -1 + } + Ok(None) => { + state.fail("text does not exist"); + -1 + } + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_splice( + object_handle: u32, + index: u32, + delete_count: i32, + value_pointer: u32, + value_length: u32, +) -> i32 { + let value = match input_string(value_pointer, value_length) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state + .doc + .splice_text(&object, index as usize, delete_count as isize, &value) + { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_update( + object_handle: u32, + value_pointer: u32, + value_length: u32, +) -> i32 { + let value = match input_string(value_pointer, value_length) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.update_text(&object, &value) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +fn expand_from_str(value: &str) -> Result { + match value { + "before" => Ok(ExpandMark::Before), + "after" => Ok(ExpandMark::After), + "both" => Ok(ExpandMark::Both), + "none" => Ok(ExpandMark::None), + other => Err(format!("unknown mark expansion {other:?}")), + } +} + +fn spans_from_json(value: &serde_json::Value) -> Result, String> { + let array = value + .as_array() + .ok_or_else(|| "spans must be an array".to_owned())?; + let mut spans = Vec::with_capacity(array.len()); + + for entry in array { + let span_type = entry + .get("type") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "span type is missing".to_owned())?; + + match span_type { + "text" => { + let text = entry + .get("text") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "text span is missing text".to_owned())? + .to_owned(); + + let marks = match entry.get("marks") { + Some(serde_json::Value::Object(map)) if !map.is_empty() => { + let mut set = Vec::with_capacity(map.len()); + for (name, value) in map { + set.push((name.clone(), scalar_from_value(value)?)); + } + Some(std::sync::Arc::new(set.into_iter().collect::())) + } + _ => None, + }; + + spans.push(Span::Text { text, marks }); + } + "block" => { + let attributes = entry + .get("block") + .ok_or_else(|| "block span is missing attributes".to_owned())?; + match hydrate_from_json(attributes)? { + automerge::hydrate::Value::Map(map) => spans.push(Span::Block(map)), + _ => return Err("block span attributes must be a map".to_owned()), + } + } + other => return Err(format!("unsupported span type {other:?}")), + } + } + + Ok(spans) +} + +fn hydrate_from_json( + value: &serde_json::Value, +) -> Result { + use automerge::hydrate::Value as HydrateValue; + + match value { + serde_json::Value::Null => Ok(HydrateValue::Scalar(ScalarValue::Null)), + serde_json::Value::Bool(value) => Ok(HydrateValue::Scalar(ScalarValue::Boolean(*value))), + serde_json::Value::Number(number) => { + if let Some(value) = number.as_i64() { + Ok(HydrateValue::Scalar(ScalarValue::Int(value))) + } else if let Some(value) = number.as_u64() { + Ok(HydrateValue::Scalar(ScalarValue::Uint(value))) + } else if let Some(value) = number.as_f64() { + Ok(HydrateValue::Scalar(ScalarValue::F64(value))) + } else { + Err("unsupported JSON number".to_owned()) + } + } + serde_json::Value::String(value) => { + Ok(HydrateValue::Scalar(ScalarValue::Str(value.as_str().into()))) + } + serde_json::Value::Array(items) => { + let mut values = Vec::with_capacity(items.len()); + for item in items { + values.push(hydrate_from_json(item)?); + } + Ok(HydrateValue::List(values.into())) + } + serde_json::Value::Object(entries) => { + let mut map = std::collections::HashMap::with_capacity(entries.len()); + for (key, value) in entries { + map.insert(key.clone(), hydrate_from_json(value)?); + } + Ok(HydrateValue::Map(map.into())) + } + } +} + +fn config_from_json(value: &serde_json::Value) -> Result { + let mut config = UpdateSpansConfig::default(); + + if let Some(default) = value.get("defaultExpand").and_then(serde_json::Value::as_str) { + config.default_expand = expand_from_str(default)?; + } + + if let Some(serde_json::Value::Object(map)) = value.get("perMarkExpands") { + for (name, expand) in map { + let expand = expand + .as_str() + .ok_or_else(|| "per-mark expand must be a string".to_owned())?; + config + .per_mark_expands + .insert(name.clone(), expand_from_str(expand)?); + } + } + + Ok(config) +} + +#[no_mangle] +pub extern "C" fn am_update_spans( + object_handle: u32, + spans_pointer: u32, + spans_length: u32, + config_pointer: u32, + config_length: u32, +) -> i32 { + let spans_value: serde_json::Value = + match serde_json::from_slice(&input_bytes(spans_pointer, spans_length)) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error.to_string())), + }; + let config_value: serde_json::Value = + match serde_json::from_slice(&input_bytes(config_pointer, config_length)) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error.to_string())), + }; + + let spans = match spans_from_json(&spans_value) { + Ok(spans) => spans, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let config = match config_from_json(&config_value) { + Ok(config) => config, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.update_spans(&object, config, spans) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_mark( + object_handle: u32, + start: u32, + end: u32, + name_pointer: u32, + name_length: u32, + value_pointer: u32, + value_length: u32, + expand_pointer: u32, + expand_length: u32, +) -> i32 { + let name = match input_string(name_pointer, name_length) { + Ok(name) => name, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let value = match input_scalar(value_pointer, value_length) { + Ok(value) => value, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let expand = match input_string(expand_pointer, expand_length) + .and_then(|value| parse_mark_expand(&value)) + { + Ok(expand) => expand, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let mark = Mark::new(name, value, start as usize, end as usize); + match state.doc.mark(&object, mark, expand) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_split_block(object_handle: u32, index: u32) -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match state.doc.split_block(&object, index as usize) { + Ok(block) => match state.push_object(block) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_join_block(object_handle: u32, index: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.join_block(&object, index as usize) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_replace_block(object_handle: u32, index: u32) -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match state.doc.replace_block(&object, index as usize) { + Ok(block) => match state.push_object(block) { + Ok(handle) => { + state.error.clear(); + i64::from(handle) + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text(object_handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.text(&object) { + Ok(text) => { + state.output = text.into_bytes(); + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_at(object_handle: u32, heads_pointer: u32, heads_length: u32) -> i32 { + let heads = match input_heads(heads_pointer, heads_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + match state.doc.text_at(&object, &heads) { + Ok(text) => { + state.output = text.into_bytes(); + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_spans(object_handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let spans = match state.doc.spans(&object) { + Ok(spans) => spans, + Err(error) => return state.fail(error), + }; + + let values = spans.map(span_to_json).collect::>(); + + match serde_json::to_vec(&values) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +fn span_to_json(span: automerge::Span) -> serde_json::Value { + match span { + automerge::Span::Text { text, marks } => { + let mut value = serde_json::Map::new(); + value.insert("type".to_owned(), serde_json::json!("text")); + value.insert("value".to_owned(), serde_json::json!(text.to_string())); + if let Some(marks) = marks { + let marks = marks + .iter() + .map(|(name, value)| (name.to_string(), scalar_to_json(value))) + .collect(); + value.insert("marks".to_owned(), serde_json::Value::Object(marks)); + } + serde_json::Value::Object(value) + } + automerge::Span::Block(block) => serde_json::json!({ + "type": "block", + "value": hydrate_map_to_json(&block), + }), + } +} + +#[no_mangle] +pub extern "C" fn am_text_spans_at( + object_handle: u32, + heads_pointer: u32, + heads_length: u32, +) -> i32 { + let heads = match input_heads(heads_pointer, heads_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let spans = match state.doc.spans_at(&object, &heads) { + Ok(spans) => spans, + Err(error) => return state.fail(error), + }; + + let values = spans.map(span_to_json).collect::>(); + + match serde_json::to_vec(&values) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +fn marks_to_output(marks: Vec) -> Result, String> { + let mut values = Vec::with_capacity(marks.len()); + for mark in &marks { + values.push(serde_json::json!({ + "start": mark.start, + "end": mark.end, + "name": mark.name(), + "value": scalar_json(mark.value())?, + })); + } + + serde_json::to_vec(&values).map_err(|error| error.to_string()) +} + +#[no_mangle] +pub extern "C" fn am_marks(object_handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => return state.fail(error), + }; + let marks = match state.doc.marks(&object) { + Ok(marks) => marks, + Err(error) => return state.fail(error), + }; + match marks_to_output(marks) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_marks_at(object_handle: u32, heads_pointer: u32, heads_length: u32) -> i32 { + let heads = match input_heads(heads_pointer, heads_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, Err(error) => return state.fail(error), }; - match state.doc.text(&object) { - Ok(text) => { - state.output = text.into_bytes(); + let marks = match state.doc.marks_at(&object, &heads) { + Ok(marks) => marks, + Err(error) => return state.fail(error), + }; + match marks_to_output(marks) { + Ok(output) => { + state.output = output; state.error.clear(); 0 } @@ -380,43 +1771,43 @@ pub extern "C" fn am_text(object_handle: u32) -> i32 { } #[no_mangle] -pub extern "C" fn am_text_spans(object_handle: u32) -> i32 { +pub extern "C" fn am_text_cursor(object_handle: u32, index: u32) -> i32 { STATE.with(|state| { let mut state = state.borrow_mut(); let object = match state.object(object_handle) { Ok(object) => object, Err(error) => return state.fail(error), }; - let spans = match state.doc.spans(&object) { - Ok(spans) => spans, + match state.doc.get_cursor(&object, index as usize, None) { + Ok(cursor) => { + state.output = cursor.to_bytes(); + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_text_cursor_moving(object_handle: u32, index: u32, move_before: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let object = match state.object(object_handle) { + Ok(object) => object, Err(error) => return state.fail(error), }; - - let values = spans - .map(|span| match span { - automerge::Span::Text { text, marks } => { - let mut value = serde_json::Map::new(); - value.insert("type".to_owned(), serde_json::json!("text")); - value.insert("value".to_owned(), serde_json::json!(text.to_string())); - if let Some(marks) = marks { - let marks = marks - .iter() - .map(|(name, value)| (name.to_string(), scalar_to_json(value))) - .collect(); - value.insert("marks".to_owned(), serde_json::Value::Object(marks)); - } - serde_json::Value::Object(value) - } - automerge::Span::Block(block) => serde_json::json!({ - "type": "block", - "value": hydrate_map_to_json(&block), - }), - }) - .collect::>(); - - match serde_json::to_vec(&values) { - Ok(output) => { - state.output = output; + let movement = if move_before != 0 { + MoveCursor::Before + } else { + MoveCursor::After + }; + match state + .doc + .get_cursor_moving(&object, index as usize, None, movement) + { + Ok(cursor) => { + state.output = cursor.to_bytes(); state.error.clear(); 0 } @@ -426,14 +1817,33 @@ pub extern "C" fn am_text_spans(object_handle: u32) -> i32 { } #[no_mangle] -pub extern "C" fn am_text_cursor(object_handle: u32, index: u32) -> i32 { +pub extern "C" fn am_text_cursor_moving_at( + object_handle: u32, + index: u32, + move_before: u32, + heads_pointer: u32, + heads_length: u32, +) -> i32 { + let heads = match input_heads(heads_pointer, heads_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { let mut state = state.borrow_mut(); let object = match state.object(object_handle) { Ok(object) => object, Err(error) => return state.fail(error), }; - match state.doc.get_cursor(&object, index as usize, None) { + let movement = if move_before != 0 { + MoveCursor::Before + } else { + MoveCursor::After + }; + match state + .doc + .get_cursor_moving(&object, index as usize, Some(&heads), movement) + { Ok(cursor) => { state.output = cursor.to_bytes(); state.error.clear(); @@ -547,6 +1957,48 @@ pub extern "C" fn am_commit( }) } +#[no_mangle] +pub extern "C" fn am_empty_commit( + message_pointer: u32, + message_length: u32, + timestamp_seconds: i64, +) -> i32 { + let message = match input_string(message_pointer, message_length) { + Ok(message) => message, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let options = CommitOptions::default() + .with_message(message) + .with_time(timestamp_seconds); + let hash = state.doc.empty_change(options); + state.output = hash.as_ref().to_vec(); + state.error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_rollback() -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + match i64::try_from(state.doc.rollback()) { + Ok(cancelled) => { + state.objects.clear(); + state.objects.push(ROOT); + state.error.clear(); + cancelled + } + Err(_) => { + state.fail("rollback operation count exceeds i64"); + -1 + } + } + }) +} + #[no_mangle] pub extern "C" fn am_heads() -> i32 { STATE.with(|state| { @@ -562,6 +2014,349 @@ pub extern "C" fn am_heads() -> i32 { 0 } +// am_isolate pins the document to a set of heads (a concatenation of 32-byte +// change hashes) so subsequent reads and writes are scoped to that frontier, +// mirroring AutoCommit::isolate. +#[no_mangle] +pub extern "C" fn am_isolate(pointer: u32, length: u32) -> i32 { + let bytes = input_bytes(pointer, length); + if bytes.len() % 32 != 0 { + return STATE.with(|state| { + state + .borrow_mut() + .fail("isolation heads must be a multiple of 32 bytes") + }); + } + + let mut heads = Vec::with_capacity(bytes.len() / 32); + for chunk in bytes.chunks(32) { + match ChangeHash::try_from(chunk) { + Ok(hash) => heads.push(hash), + Err(error) => { + return STATE.with(|state| { + state.borrow_mut().fail(format!("invalid head: {error}")) + }); + } + } + } + + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.doc.isolate(&heads); + state.error.clear(); + }); + + 0 +} + +// am_integrate ends isolation, returning to the full document history, mirroring +// AutoCommit::integrate. +#[no_mangle] +pub extern "C" fn am_integrate() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.doc.integrate(); + state.error.clear(); + }); + + 0 +} + +// am_bloom_contains builds a sync Bloom filter from a set of change hashes and +// reports whether it (possibly falsely) contains a target hash. The input is a +// concatenation of 32-byte change hashes: the first hash is the target and the +// remaining hashes seed the filter. The single output byte is 1 when the filter +// contains the target and 0 otherwise. It exists so parity tests can reproduce +// the upstream Bloom false-positive search deterministically. +#[no_mangle] +pub extern "C" fn am_bloom_contains(pointer: u32, length: u32) -> i32 { + let bytes = input_bytes(pointer, length); + if bytes.len() < 32 || bytes.len() % 32 != 0 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.error = "bloom input must be a non-empty multiple of 32 bytes".to_string(); + }); + return -1; + } + + let target = match ChangeHash::try_from(&bytes[0..32]) { + Ok(hash) => hash, + Err(error) => { + STATE.with(|state| { + state.borrow_mut().error = format!("invalid target hash: {error}"); + }); + return -1; + } + }; + + let mut hashes = Vec::with_capacity(bytes.len() / 32 - 1); + for chunk in bytes[32..].chunks(32) { + match ChangeHash::try_from(chunk) { + Ok(hash) => hashes.push(hash), + Err(error) => { + STATE.with(|state| { + state.borrow_mut().error = format!("invalid seed hash: {error}"); + }); + return -1; + } + } + } + + let bloom = automerge::sync::BloomFilter::from_hashes(hashes.iter()); + let contains = bloom.contains_hash(&target); + + STATE.with(|state| state.borrow_mut().error.clear()); + + i32::from(contains) +} + +fn patch_prop_json(prop: &Prop) -> serde_json::Value { + match prop { + Prop::Map(key) => serde_json::json!({ "key": key }), + Prop::Seq(index) => serde_json::json!({ "index": index }), + } +} + +fn patch_value_json(value: &Value<'_>, id: &ObjId) -> Result { + match value { + Value::Scalar(scalar) => Ok(serde_json::json!({ "scalar": scalar_json(scalar)? })), + Value::Object(object_type) => Ok(serde_json::json!({ + "object": encode_object_type(*object_type), + "id": id.to_string(), + })), + } +} + +fn patches_to_output(patches: &[Patch]) -> Result, String> { + let mut output = Vec::with_capacity(patches.len()); + for patch in patches { + let object = patch.obj.to_string(); + let action = match &patch.action { + PatchAction::PutMap { + key, + value, + conflict, + } => serde_json::json!({ + "type": "put_map", + "key": key, + "value": patch_value_json(&value.0, &value.1)?, + "conflict": conflict, + }), + PatchAction::PutSeq { + index, + value, + conflict, + } => serde_json::json!({ + "type": "put_seq", + "index": index, + "value": patch_value_json(&value.0, &value.1)?, + "conflict": conflict, + }), + PatchAction::Insert { index, values } => { + let mut encoded = Vec::with_capacity(values.len()); + for (value, id, conflict) in values.iter() { + encoded.push(serde_json::json!({ + "value": patch_value_json(value, id)?, + "conflict": conflict, + })); + } + serde_json::json!({ "type": "insert", "index": index, "values": encoded }) + } + PatchAction::SpliceText { + index, + value, + marks, + } => { + let mut action = serde_json::json!({ + "type": "splice_text", + "index": index, + "text": value.make_string(), + }); + if let Some(marks) = marks { + let encoded = marks + .iter() + .map(|(name, value)| { + Ok(serde_json::json!({ + "name": name, + "value": scalar_json(value)?, + })) + }) + .collect::, String>>()?; + action["marks"] = serde_json::Value::Array(encoded); + } + action + } + PatchAction::Increment { prop, value } => serde_json::json!({ + "type": "increment", + "prop": patch_prop_json(prop), + "value": value, + }), + PatchAction::Conflict { prop } => serde_json::json!({ + "type": "conflict", + "prop": patch_prop_json(prop), + }), + PatchAction::DeleteMap { key } => serde_json::json!({ + "type": "delete_map", + "key": key, + }), + PatchAction::DeleteSeq { index, length } => serde_json::json!({ + "type": "delete_seq", + "index": index, + "length": length, + }), + PatchAction::Mark { marks } => { + let encoded = marks + .iter() + .map(|mark| { + Ok(serde_json::json!({ + "start": mark.start, + "end": mark.end, + "name": mark.name(), + "value": scalar_json(mark.value())?, + })) + }) + .collect::, String>>()?; + serde_json::json!({ "type": "mark", "marks": encoded }) + } + }; + output.push(serde_json::json!({ "obj": object, "action": action })); + } + + serde_json::to_vec(&output).map_err(|error| error.to_string()) +} + +#[no_mangle] +pub extern "C" fn am_diff( + before_pointer: u32, + before_length: u32, + after_pointer: u32, + after_length: u32, +) -> i32 { + let before = match input_heads(before_pointer, before_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + let after = match input_heads(after_pointer, after_length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let patches = state.doc.diff(&before, &after); + match patches_to_output(&patches) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_update_diff_cursor() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.doc.update_diff_cursor(); + state.error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_diff_incremental() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let patches = state.doc.diff_incremental(); + match patches_to_output(&patches) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_current_state() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let patches = state.doc.document().current_state(); + match patches_to_output(&patches) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_stats() -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let stats = state.doc.stats(); + let value = serde_json::json!({ + "numChanges": stats.num_changes, + "numOps": stats.num_ops, + "numActors": stats.num_actors, + }); + match serde_json::to_vec(&value) { + Ok(output) => { + state.output = output; + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_has_heads(pointer: u32, length: u32) -> i32 { + let heads = match input_heads(pointer, length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { + let mut state = state.borrow_mut(); + let has_heads = heads + .iter() + .all(|head| state.doc.get_change_by_hash(head).is_some()); + state.error.clear(); + if has_heads { + 1 + } else { + 0 + } + }) +} + +#[no_mangle] +pub extern "C" fn am_missing_dependencies(pointer: u32, length: u32) -> i32 { + let heads = match input_heads(pointer, length) { + Ok(heads) => heads, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.output = state + .doc + .get_missing_deps(&heads) + .into_iter() + .flat_map(|hash| hash.0) + .collect(); + state.error.clear(); + 0 + }) +} + #[no_mangle] pub extern "C" fn am_merge(pointer: u32, length: u32) -> i32 { let bytes = input_bytes(pointer, length); @@ -599,6 +2394,42 @@ pub extern "C" fn am_sync_new() -> i64 { }) } +#[no_mangle] +pub extern "C" fn am_sync_set_read_only(handle: u32, read_only: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let Some(sync_state) = state + .sync_states + .get_mut(handle as usize) + .and_then(Option::as_mut) + else { + return state.fail(format!("invalid sync state {handle}")); + }; + sync_state.set_read_only(read_only != 0); + state.error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_peer_read_only(handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let Some(sync_state) = state + .sync_states + .get(handle as usize) + .and_then(Option::as_ref) + else { + return state.fail(format!("invalid sync state {handle}")); + }; + if sync_state.is_peer_read_only() { + 1 + } else { + 0 + } + }) +} + #[no_mangle] pub extern "C" fn am_sync_free(handle: u32) -> i32 { STATE.with(|state| { diff --git a/pkg/automerge/invariants_test.go b/pkg/automerge/invariants_test.go new file mode 100644 index 0000000000..27e9f07d68 --- /dev/null +++ b/pkg/automerge/invariants_test.go @@ -0,0 +1,629 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestDocument_AppliesDependentChangesInAnyOrder(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := automerge.New(ctx, actor(100)) + require.NoError(t, err) + closeDocument(t, base) + baseText, err := base.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, baseText.Splice(ctx, 0, 0, "A")) + _, err = base.Commit(ctx, "base", commitTime) + require.NoError(t, err) + baseHeads, err := base.Heads(ctx) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + source, err := automerge.Load(ctx, baseData, actor(101)) + require.NoError(t, err) + closeDocument(t, source) + sourceText, err := source.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, sourceText.Splice(ctx, 1, 0, "B")) + parentHash, err := source.Commit(ctx, "parent", commitTime.Add(time.Second)) + require.NoError(t, err) + require.NoError(t, sourceText.Splice(ctx, 2, 0, "C")) + childHash, err := source.Commit(ctx, "child", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + changes, err := source.ChangesSince(ctx, baseHeads) + require.NoError(t, err) + require.Len(t, changes, 2) + assert.ElementsMatch( + t, + []automerge.Hash{parentHash, childHash}, + []automerge.Hash{changes[0].Hash, changes[1].Hash}, + ) + + target, err := automerge.Load(ctx, baseData, actor(102)) + require.NoError(t, err) + closeDocument(t, target) + require.NoError(t, target.ApplyChanges(ctx, [][]byte{changes[1].Bytes})) + missing, err := target.MissingDependencies( + ctx, + []automerge.Hash{childHash}, + ) + require.NoError(t, err) + assert.Equal(t, []automerge.Hash{parentHash}, missing) + require.NoError(t, target.ApplyChanges(ctx, [][]byte{changes[0].Bytes})) + missing, err = target.MissingDependencies( + ctx, + []automerge.Hash{childHash}, + ) + require.NoError(t, err) + assert.Empty(t, missing) + require.NoError(t, target.ApplyChanges(ctx, [][]byte{ + changes[0].Bytes, + changes[1].Bytes, + })) + + targetText, err := target.Text(ctx, "body") + require.NoError(t, err) + value, err := targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "ABC", value) + + sourceHeads, err := source.Heads(ctx) + require.NoError(t, err) + targetHeads, err := target.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, sourceHeads, targetHeads) +} + +func TestDocument_InvalidChangesDoNotMutateState(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(119)) + require.NoError(t, err) + closeDocument(t, document) + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Stable")) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + headsBefore, err := document.Heads(ctx) + require.NoError(t, err) + + err = document.ApplyChanges(ctx, [][]byte{ + []byte("invalid"), + }) + require.Error(t, err) + + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Stable", value) + + headsAfter, err := document.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, headsBefore, headsAfter) + + var unknown automerge.Hash + + unknown[0] = 1 + _, err = document.ChangesSince(ctx, []automerge.Hash{unknown}) + require.Error(t, err) +} + +func TestDocument_IncrementalSaveLoadParity(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + source func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error) + target func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error) + }{ + "native to reference": { + source: automerge.New, + target: automerge.NewReference, + }, + "reference to native": { + source: automerge.NewReference, + target: automerge.New, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + source, err := test.source(ctx, actor(151)) + require.NoError(t, err) + closeDocument(t, source) + + target, err := test.target(ctx, actor(152)) + require.NoError(t, err) + closeDocument(t, target) + + text, err := source.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "A")) + _, err = source.Commit(ctx, "first", commitTime) + require.NoError(t, err) + first, err := source.SaveIncremental(ctx) + require.NoError(t, err) + require.NotEmpty(t, first) + + empty, err := source.SaveIncremental(ctx) + require.NoError(t, err) + assert.Empty(t, empty) + + applied, err := target.LoadIncremental(ctx, first) + require.NoError(t, err) + assert.Positive(t, applied) + + targetText, err := target.Text(ctx, "body") + require.NoError(t, err) + value, err := targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "A", value) + + applied, err = target.LoadIncremental(ctx, first) + require.NoError(t, err) + assert.Zero(t, applied) + + require.NoError(t, text.Splice(ctx, 1, 0, "B")) + _, err = source.Commit(ctx, "second", commitTime.Add(time.Second)) + require.NoError(t, err) + second, err := source.SaveIncremental(ctx) + require.NoError(t, err) + require.NotEmpty(t, second) + applied, err = target.LoadIncremental(ctx, second) + require.NoError(t, err) + assert.Positive(t, applied) + + value, err = targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AB", value) + + sourceHeads, err := source.Heads(ctx) + require.NoError(t, err) + targetHeads, err := target.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, sourceHeads, targetHeads) + + _, err = source.Save(ctx) + require.NoError(t, err) + empty, err = source.SaveIncremental(ctx) + require.NoError(t, err) + assert.Empty(t, empty) + }) + } +} + +func TestDocument_IncrementalLoadIgnoresCorruptTail(t *testing.T) { + t.Parallel() + + ctx := context.Background() + source, err := automerge.NewReference(ctx, actor(169)) + require.NoError(t, err) + closeDocument(t, source) + require.NoError(t, source.PutString(ctx, "key", "value")) + _, err = source.Commit(ctx, "value", commitTime) + require.NoError(t, err) + data, err := source.Save(ctx) + require.NoError(t, err) + + data = append(data, 1, 2, 3, 4) + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + document, err := factory(ctx, actor(170)) + require.NoError(t, err) + closeDocument(t, document) + applied, err := document.LoadIncremental(ctx, data) + require.NoError(t, err) + assert.Positive(t, applied) + + value, err := document.String(ctx, "key") + require.NoError(t, err) + assert.Equal(t, "value", value) + }) + } +} + +func TestDocument_MergedChangesForwardToThirdPeer(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base := newBaseDocument(t) + + relay, err := automerge.Load(ctx, base, actor(103)) + require.NoError(t, err) + closeDocument(t, relay) + + source, err := automerge.Load(ctx, base, actor(104)) + require.NoError(t, err) + closeDocument(t, source) + sourceText, err := source.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, sourceText.Splice(ctx, 5, 0, " forwarded")) + _, err = source.Commit(ctx, "source edit", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = relay.Merge(ctx, source) + require.NoError(t, err) + + third, err := automerge.Load(ctx, base, actor(105)) + require.NoError(t, err) + closeDocument(t, third) + + relaySync, err := relay.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, relaySync) + + thirdSync, err := third.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, thirdSync) + + synchronize(t, relaySync, thirdSync) + + thirdText, err := third.Text(ctx, "body") + require.NoError(t, err) + value, err := thirdText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Hello forwarded", value) + + relayHeads, err := relay.Heads(ctx) + require.NoError(t, err) + thirdHeads, err := third.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, relayHeads, thirdHeads) +} + +func TestSyncState_DuplicateMessagesAreIdempotent(t *testing.T) { + t.Parallel() + + ctx := context.Background() + source, err := automerge.New(ctx, actor(106)) + require.NoError(t, err) + closeDocument(t, source) + sourceText, err := source.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, sourceText.Splice(ctx, 0, 0, "Once")) + _, err = source.Commit(ctx, "create body", commitTime) + require.NoError(t, err) + + target, err := automerge.New(ctx, actor(107)) + require.NoError(t, err) + closeDocument(t, target) + + sourceSync, err := source.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, sourceSync) + + targetSync, err := target.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, targetSync) + + message, ok, err := sourceSync.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, targetSync.ReceiveMessage(ctx, message)) + require.NoError(t, targetSync.ReceiveMessage(ctx, message)) + synchronize(t, sourceSync, targetSync) + + targetText, err := target.Text(ctx, "body") + require.NoError(t, err) + value, err := targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Once", value) + + sourceHeads, err := source.Heads(ctx) + require.NoError(t, err) + targetHeads, err := target.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, sourceHeads, targetHeads) +} + +func TestSyncState_ResumesPersistedSession(t *testing.T) { + t.Parallel() + + ctx := context.Background() + left, err := automerge.New(ctx, actor(108)) + require.NoError(t, err) + closeDocument(t, left) + leftText, err := left.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, leftText.Splice(ctx, 0, 0, "A")) + _, err = left.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + + right, err := automerge.New(ctx, actor(109)) + require.NoError(t, err) + closeDocument(t, right) + + leftSync, err := left.NewSyncState(ctx) + require.NoError(t, err) + + rightSync, err := right.NewSyncState(ctx) + require.NoError(t, err) + synchronize(t, leftSync, rightSync) + + leftState, err := leftSync.Save(ctx) + require.NoError(t, err) + rightState, err := rightSync.Save(ctx) + require.NoError(t, err) + require.NoError(t, leftSync.Close(ctx)) + require.NoError(t, rightSync.Close(ctx)) + + resumedLeft, err := left.LoadSyncState(ctx, leftState) + require.NoError(t, err) + closeSyncState(t, resumedLeft) + + resumedRight, err := right.LoadSyncState(ctx, rightState) + require.NoError(t, err) + closeSyncState(t, resumedRight) + + require.NoError(t, leftText.Splice(ctx, 1, 0, "B")) + _, err = left.Commit(ctx, "incremental", commitTime.Add(time.Second)) + require.NoError(t, err) + synchronize(t, resumedLeft, resumedRight) + + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + value, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AB", value) +} + +func TestSyncState_ResendsInFlightMessageAfterRestore(t *testing.T) { + t.Parallel() + + ctx := context.Background() + source, err := automerge.New(ctx, actor(120)) + require.NoError(t, err) + closeDocument(t, source) + sourceText, err := source.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, sourceText.Splice(ctx, 0, 0, "Recovered")) + _, err = source.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + + target, err := automerge.New(ctx, actor(121)) + require.NoError(t, err) + closeDocument(t, target) + + sourceSync, err := source.NewSyncState(ctx) + require.NoError(t, err) + _, ok, err := sourceSync.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + + sourceState, err := sourceSync.Save(ctx) + require.NoError(t, err) + require.NoError(t, sourceSync.Close(ctx)) + + targetSync, err := target.NewSyncState(ctx) + require.NoError(t, err) + targetState, err := targetSync.Save(ctx) + require.NoError(t, err) + require.NoError(t, targetSync.Close(ctx)) + + resumedSource, err := source.LoadSyncState(ctx, sourceState) + require.NoError(t, err) + closeSyncState(t, resumedSource) + + resumedTarget, err := target.LoadSyncState(ctx, targetState) + require.NoError(t, err) + closeSyncState(t, resumedTarget) + + message, ok, err := resumedSource.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NotEmpty(t, message) + require.NoError(t, resumedTarget.ReceiveMessage(ctx, message)) + synchronize(t, resumedSource, resumedTarget) + + targetText, err := target.Text(ctx, "body") + require.NoError(t, err) + value, err := targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Recovered", value) +} + +func TestDocument_ThreeWayMergeIsAssociative(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base := newBaseDocument(t) + documents := make([]*automerge.Document, 3) + + for i := range documents { + document, err := automerge.Load(ctx, base, actor(byte(110+i))) + require.NoError(t, err) + closeDocument(t, document) + text, err := document.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 5, 0, string(rune('A'+i)))) + _, err = document.Commit( + ctx, + "concurrent edit", + commitTime.Add(time.Duration(i+1)*time.Second), + ) + require.NoError(t, err) + + documents[i] = document + } + + first, err := automerge.Load(ctx, base, actor(113)) + require.NoError(t, err) + closeDocument(t, first) + _, err = first.Merge(ctx, documents[0]) + require.NoError(t, err) + _, err = first.Merge(ctx, documents[1]) + require.NoError(t, err) + _, err = first.Merge(ctx, documents[2]) + require.NoError(t, err) + + second, err := automerge.Load(ctx, base, actor(114)) + require.NoError(t, err) + closeDocument(t, second) + _, err = second.Merge(ctx, documents[2]) + require.NoError(t, err) + _, err = second.Merge(ctx, documents[0]) + require.NoError(t, err) + _, err = second.Merge(ctx, documents[1]) + require.NoError(t, err) + + firstText, err := first.Text(ctx, "body") + require.NoError(t, err) + firstValue, err := firstText.String(ctx) + require.NoError(t, err) + secondText, err := second.Text(ctx, "body") + require.NoError(t, err) + secondValue, err := secondText.String(ctx) + require.NoError(t, err) + assert.Equal(t, firstValue, secondValue) + + firstHeads, err := first.Heads(ctx) + require.NoError(t, err) + secondHeads, err := second.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, firstHeads, secondHeads) + + firstData, err := first.Save(ctx) + require.NoError(t, err) + reference, err := automerge.LoadReference(ctx, firstData, actor(115)) + require.NoError(t, err) + closeDocument(t, reference) + referenceText, err := reference.Text(ctx, "body") + require.NoError(t, err) + referenceValue, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, firstValue, referenceValue) +} + +func TestSyncState_ThreePeerRelayConvergesWithReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + first, err := automerge.New(ctx, actor(116)) + require.NoError(t, err) + closeDocument(t, first) + firstText, err := first.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, firstText.Splice(ctx, 0, 0, "A")) + _, err = first.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + + second, err := automerge.NewReference(ctx, actor(117)) + require.NoError(t, err) + closeDocument(t, second) + + firstSecond, err := first.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, firstSecond) + + secondFirst, err := second.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, secondFirst) + synchronize(t, firstSecond, secondFirst) + + third, err := automerge.New(ctx, actor(118)) + require.NoError(t, err) + closeDocument(t, third) + + secondThird, err := second.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, secondThird) + + thirdSecond, err := third.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, thirdSecond) + synchronize(t, secondThird, thirdSecond) + + secondText, err := second.Text(ctx, "body") + require.NoError(t, err) + thirdText, err := third.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, firstText.Splice(ctx, 1, 0, "1")) + _, err = first.Commit(ctx, "first edit", commitTime.Add(time.Second)) + require.NoError(t, err) + require.NoError(t, secondText.Splice(ctx, 1, 0, "2")) + _, err = second.Commit(ctx, "second edit", commitTime.Add(2*time.Second)) + require.NoError(t, err) + require.NoError(t, thirdText.Splice(ctx, 1, 0, "3")) + _, err = third.Commit(ctx, "third edit", commitTime.Add(3*time.Second)) + require.NoError(t, err) + + synchronize(t, firstSecond, secondFirst) + synchronize(t, secondThird, thirdSecond) + + thirdFirst, err := third.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, thirdFirst) + + firstThird, err := first.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, firstThird) + synchronize(t, thirdFirst, firstThird) + synchronize(t, firstSecond, secondFirst) + synchronize(t, secondThird, thirdSecond) + + firstValue, err := firstText.String(ctx) + require.NoError(t, err) + secondValue, err := secondText.String(ctx) + require.NoError(t, err) + thirdValue, err := thirdText.String(ctx) + require.NoError(t, err) + assert.Equal(t, firstValue, secondValue) + assert.Equal(t, firstValue, thirdValue) + + firstHeads, err := first.Heads(ctx) + require.NoError(t, err) + secondHeads, err := second.Heads(ctx) + require.NoError(t, err) + thirdHeads, err := third.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, firstHeads, secondHeads) + assert.ElementsMatch(t, firstHeads, thirdHeads) +} diff --git a/pkg/automerge/isolate_parity_test.go b/pkg/automerge/isolate_parity_test.go new file mode 100644 index 0000000000..176d122818 --- /dev/null +++ b/pkg/automerge/isolate_parity_test.go @@ -0,0 +1,379 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file reproduces the isolate/integrate transaction-view tests +// (can_isolate and can_transaction_at from rust/automerge/tests/test.rs and +// update_text_change_at from rust/automerge/tests/text.rs). Isolation pins reads +// and writes to a historical frontier while committed changes still accumulate +// in the full history and become visible after integrate. Each scenario runs on +// the native and reference engines and their observations must agree. + +package automerge_test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func textString(t *testing.T, ctx context.Context, text *automerge.Text) string { + t.Helper() + + value, err := text.String(ctx) + require.NoError(t, err) + + return value +} + +func rootInt(t *testing.T, ctx context.Context, document *automerge.Document, key string) int64 { + t.Helper() + + value, err := document.Root().Scalar(ctx, key) + require.NoError(t, err) + + return value.Int +} + +func putInt64( + t *testing.T, + ctx context.Context, + document *automerge.Document, + key string, + value int64, +) { + t.Helper() + + require.NoError(t, document.Root().PutScalar( + ctx, + key, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: value}, + )) +} + +// canIsolateObservations records the values observed at each checkpoint of the +// can_isolate scenario so the native and reference runs can be compared. +type canIsolateObservations struct { + pinnedText string + pinnedSize int64 + editedText string + editedSize int64 + afterMergeSize int64 + afterMergeHas bool + rePinnedText string + rePinnedSize int64 + integratedText string + integratedHas bool + finalText string + finalSize int64 +} + +func runCanIsolate(t *testing.T, ctx context.Context, engine rustParityEngine) canIsolateObservations { + t.Helper() + + doc1, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, doc1) + + text, err := doc1.CreateText(ctx, "text") + require.NoError(t, err) + + putInt64(t, ctx, doc1, "size", 100) + require.NoError(t, text.Splice(ctx, 0, 0, "aaabbbccc")) + _, err = doc1.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + heads1, err := doc1.Heads(ctx) + require.NoError(t, err) + + putInt64(t, ctx, doc1, "size", 150) + _, err = doc1.Commit(ctx, "size150", commitTime) + require.NoError(t, err) + + require.NoError(t, doc1.Isolate(ctx, heads1)) + + doc2, err := doc1.Fork(ctx, actor(0x02)) + require.NoError(t, err) + closeDocument(t, doc2) + + putInt64(t, ctx, doc2, "other", 999) + text2, err := doc2.Text(ctx, "text") + require.NoError(t, err) + require.NoError(t, text2.Splice(ctx, 9, 0, "111")) + _, err = doc2.Commit(ctx, "doc2", commitTime) + require.NoError(t, err) + + observations := canIsolateObservations{ + pinnedText: textString(t, ctx, text), + pinnedSize: rootInt(t, ctx, doc1, "size"), + } + + require.NoError(t, text.Splice(ctx, 3, 3, "QQQ")) + putInt64(t, ctx, doc1, "size", 200) + + observations.editedText = textString(t, ctx, text) + observations.editedSize = rootInt(t, ctx, doc1, "size") + + _, err = doc1.Commit(ctx, "qqq", commitTime) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + observations.afterMergeSize = rootInt(t, ctx, doc1, "size") + observations.afterMergeHas = rootHasKey(t, ctx, doc1, "other") + + require.NoError(t, doc1.Isolate(ctx, heads1)) + + observations.rePinnedText = textString(t, ctx, text) + observations.rePinnedSize = rootInt(t, ctx, doc1, "size") + + require.NoError(t, text.Splice(ctx, 3, 3, "ZZZ")) + putInt64(t, ctx, doc1, "size", 300) + _, err = doc1.Commit(ctx, "zzz", commitTime) + require.NoError(t, err) + + require.NoError(t, doc1.Integrate(ctx)) + + observations.integratedText = textString(t, ctx, text) + observations.integratedHas = rootHasKey(t, ctx, doc1, "other") + + require.NoError(t, doc1.Isolate(ctx, heads1)) + require.NoError(t, text.Splice(ctx, 3, 3, "TTT")) + putInt64(t, ctx, doc1, "size", 400) + _, err = doc1.Commit(ctx, "ttt", commitTime) + require.NoError(t, err) + + require.NoError(t, doc1.Integrate(ctx)) + + observations.finalText = textString(t, ctx, text) + observations.finalSize = rootInt(t, ctx, doc1, "size") + + return observations +} + +// TestRustText_IncorrectPatchesProducedWhenIsolatingAndIntegrating reproduces +// incorrect_patches_produced_when_isolating_and_integrating: a diff across an +// isolate/integrate cycle with a conflicting object put must reset to the isolate +// frontier and rebuild, producing deletes for the prior keys, conflicting puts, +// and a splice only for each winning object. +func TestRustText_IncorrectPatchesProducedWhenIsolatingAndIntegrating(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + run := func(engine rustParityEngine) []automerge.Patch { + doc, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, doc) + + beginning, err := doc.Heads(ctx) + require.NoError(t, err) + + name, err := doc.CreateText(ctx, "name") + require.NoError(t, err) + + newName := strings.Repeat("a", 100) + require.NoError(t, name.Splice(ctx, 0, 0, newName)) + + require.NoError(t, doc.Isolate(ctx, beginning)) + color, err := doc.CreateText(ctx, "color") + require.NoError(t, err) + require.NoError(t, color.Splice(ctx, 0, 0, "red")) + require.NoError(t, doc.Integrate(ctx)) + + _, err = doc.DiffIncremental(ctx) + require.NoError(t, err) + + require.NoError(t, doc.Isolate(ctx, beginning)) + color2, err := doc.CreateText(ctx, "color") + require.NoError(t, err) + require.NoError(t, color2.Splice(ctx, 0, 0, "unset")) + require.NoError(t, doc.Integrate(ctx)) + + patches, err := doc.DiffIncremental(ctx) + require.NoError(t, err) + + return patches + } + + native := run(rustParityEngines()[0]) + reference := run(rustParityEngines()[1]) + + require.Equal(t, reference, native) + + require.Len(t, reference, 6) + assert.Equal(t, automerge.PatchDeleteMap, reference[0].Action) + assert.Equal(t, "color", reference[0].Key) + assert.Equal(t, automerge.PatchDeleteMap, reference[1].Action) + assert.Equal(t, "name", reference[1].Key) + assert.Equal(t, automerge.PatchPutMap, reference[2].Action) + assert.Equal(t, "color", reference[2].Key) + assert.True(t, reference[2].Conflict) + assert.Equal(t, automerge.PatchPutMap, reference[3].Action) + assert.Equal(t, "name", reference[3].Key) + assert.False(t, reference[3].Conflict) + assert.Equal(t, automerge.PatchSpliceText, reference[4].Action) + assert.Equal(t, strings.Repeat("a", 100), reference[4].Text) + assert.Equal(t, automerge.PatchSpliceText, reference[5].Action) + assert.Equal(t, "unset", reference[5].Text) +} + +// TestRustText_UpdateTextChangeAt reproduces update_text_change_at: an isolated +// update_text branches from the initial heads and integrates alongside the +// concurrent update. +func TestRustText_UpdateTextChangeAt(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + run := func(engine rustParityEngine) string { + doc, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, doc) + + text, err := doc.CreateText(ctx, "text") + require.NoError(t, err) + + require.NoError(t, text.Update(ctx, "a\n")) + _, err = doc.Commit(ctx, "a", commitTime) + require.NoError(t, err) + + heads, err := doc.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.Update(ctx, "a\nb\n")) + _, err = doc.Commit(ctx, "b", commitTime) + require.NoError(t, err) + + require.NoError(t, doc.Isolate(ctx, heads)) + require.NoError(t, text.Update(ctx, "a\nc\n")) + _, err = doc.Commit(ctx, "c", commitTime) + require.NoError(t, err) + require.NoError(t, doc.Integrate(ctx)) + + return textString(t, ctx, text) + } + + native := run(rustParityEngines()[0]) + reference := run(rustParityEngines()[1]) + + require.Equal(t, reference, native) + require.Equal(t, "a\nc\nb\n", reference) +} + +// TestRustTest_CanTransactionAt reproduces can_transaction_at using the +// isolate/integrate equivalent of transaction_at: writes based at a historical +// frontier merge with the concurrent writes made since. +func TestRustTest_CanTransactionAt(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + type observation struct { + firstText string + firstSize int64 + secondText string + secondSize int64 + } + + run := func(engine rustParityEngine) observation { + doc, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, doc) + + text, err := doc.CreateText(ctx, "text") + require.NoError(t, err) + + putInt64(t, ctx, doc, "size", 100) + require.NoError(t, text.Splice(ctx, 0, 0, "aaabbbccc")) + _, err = doc.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + heads1, err := doc.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 3, 3, "QQQ")) + putInt64(t, ctx, doc, "size", 200) + _, err = doc.Commit(ctx, "qqq", commitTime) + require.NoError(t, err) + + require.NoError(t, doc.Isolate(ctx, heads1)) + require.NoError(t, text.Splice(ctx, 3, 3, "ZZZ")) + putInt64(t, ctx, doc, "size", 300) + _, err = doc.Commit(ctx, "zzz", commitTime) + require.NoError(t, err) + require.NoError(t, doc.Integrate(ctx)) + + result := observation{ + firstText: textString(t, ctx, text), + firstSize: rootInt(t, ctx, doc, "size"), + } + + require.NoError(t, doc.Isolate(ctx, heads1)) + require.NoError(t, text.Splice(ctx, 3, 3, "TTT")) + putInt64(t, ctx, doc, "size", 400) + _, err = doc.Commit(ctx, "ttt", commitTime) + require.NoError(t, err) + require.NoError(t, doc.Integrate(ctx)) + + result.secondText = textString(t, ctx, text) + result.secondSize = rootInt(t, ctx, doc, "size") + + return result + } + + native := run(rustParityEngines()[0]) + reference := run(rustParityEngines()[1]) + + require.Equal(t, reference, native) + require.Equal(t, "aaaZZZQQQccc", reference.firstText) + require.Equal(t, int64(300), reference.firstSize) + require.Equal(t, "aaaTTTZZZQQQccc", reference.secondText) + require.Equal(t, int64(400), reference.secondSize) +} + +func TestRustTest_CanIsolate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + native := runCanIsolate(t, ctx, rustParityEngines()[0]) + reference := runCanIsolate(t, ctx, rustParityEngines()[1]) + + require.Equal(t, reference, native) + + // Anchor the reference observations to the upstream expectations so the + // differential is also an absolute correctness check. + require.Equal(t, "aaabbbccc", reference.pinnedText) + require.Equal(t, int64(100), reference.pinnedSize) + require.Equal(t, "aaaQQQccc", reference.editedText) + require.Equal(t, int64(200), reference.editedSize) + require.Equal(t, int64(200), reference.afterMergeSize) + require.False(t, reference.afterMergeHas) + require.Equal(t, "aaabbbccc", reference.rePinnedText) + require.Equal(t, "aaaZZZQQQccc111", reference.integratedText) + require.True(t, reference.integratedHas) + require.Equal(t, "aaaTTTZZZQQQccc111", reference.finalText) + require.Equal(t, int64(400), reference.finalSize) +} diff --git a/pkg/automerge/js_block_parity_test.go b/pkg/automerge/js_block_parity_test.go new file mode 100644 index 0000000000..bc7da0d347 --- /dev/null +++ b/pkg/automerge/js_block_parity_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the cross-engine block behaviors from the +// upstream JavaScript suite (javascript/test/block_test.ts). Each runs on the +// native Go engine and the Rust/WASM reference engine and asserts they agree. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestJSBlock_UpdateSpansScenarios reproduces the update_spans block behaviors +// from the JavaScript block suite by comparing the materialized spans. +func TestJSBlock_UpdateSpansScenarios(t *testing.T) { + t.Parallel() + + none := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandNone} + overriding := automerge.UpdateSpansConfig{ + DefaultExpand: automerge.MarkExpandNone, + PerMarkExpands: map[string]automerge.MarkExpand{"bold": automerge.MarkExpandBoth}, + } + defaultConfig := automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} + + scenarios := []blockSpanScenario{ + { + name: "allows_updating_all_blocks_at_once", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("first thing"), + blockSpan(map[string]any{"type": "ordered-list-item", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("second thing"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("the first thing"), + blockSpan(map[string]any{"type": "unordered-list-item", "parents": []any{"ordered-list-item"}, "attrs": map[string]any{}}), + textSpan("the second thing"), + }, + config: defaultConfig, + }, + { + name: "should_update_marks", + initial: []automerge.SpanInput{textSpan("hello world")}, + target: []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" "), + textSpan(" world", "italic", markBool()), + }, + config: defaultConfig, + }, + { + name: "configuring_default_expand", + initial: nil, + target: []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" world"), + }, + config: none, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 5, 0, "!")) + }, + }, + { + name: "override_default_expand_per_mark", + initial: nil, + target: []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" world"), + }, + config: overriding, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 5, 0, "!")) + }, + }, + { + name: "updates_document_on_block_attribute_change", + initial: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "parents": []any{}, "attrs": map[string]any{}}), + textSpan("item"), + }, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"type": "paragraph", "parents": []any{"ordered-list-item"}, "attrs": map[string]any{}}), + textSpan("item"), + }, + config: defaultConfig, + post: func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 1, "A")) + }, + }, + { + name: "small_values_in_block_attributes", + initial: nil, + target: []automerge.SpanInput{ + blockSpan(map[string]any{"smallnum": 1.401298464324817e-45}), + }, + config: defaultConfig, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + if scenario.initial != nil { + require.NoError(t, text.UpdateSpans(ctx, scenario.initial, scenario.config)) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + } + + require.NoError(t, text.UpdateSpans(ctx, scenario.target, scenario.config)) + _, err = document.Commit(ctx, "target", commitTime) + require.NoError(t, err) + + if scenario.post != nil { + scenario.post(ctx, t, text) + _, err = document.Commit(ctx, "post", commitTime) + require.NoError(t, err) + } + + spans, err := text.Spans(ctx) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) + }) + } +} + +// TestJSBlock_OmittingConfigParts reproduces "should allow omitting any part of +// the update spans config": partial and absent configs are all accepted. +func TestJSBlock_OmittingConfigParts(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + spans := []automerge.SpanInput{ + textSpan("hello", "bold", markBool()), + textSpan(" world"), + } + configs := []automerge.UpdateSpansConfig{ + {DefaultExpand: automerge.MarkExpandNone}, + {PerMarkExpands: map[string]automerge.MarkExpand{"bold": automerge.MarkExpandNone}}, + {}, + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + for _, config := range configs { + require.NoError(t, text.UpdateSpans(ctx, spans, config)) + } + + _, err = document.Commit(ctx, "rounds", commitTime) + require.NoError(t, err) + + got, err := text.Spans(ctx) + require.NoError(t, err) + result[engine.name] = got + } + + assert.Equal(t, result["reference"], result["native"]) +} + +// TestJSBlock_ShowHistoricalMarks reproduces "should show historical marks": +// viewing spans at a past frontier omits marks added afterward. +func TestJSBlock_ShowHistoricalMarks(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "hello world") + require.NoError(t, text.Mark(ctx, 0, 5, "bold", markBool(), automerge.MarkExpandAfter)) + heads, err := document.Commit(ctx, "bold", commitTime) + require.NoError(t, err) + + require.NoError(t, text.Mark(ctx, 5, 11, "italic", markBool(), automerge.MarkExpandAfter)) + _, err = document.Commit(ctx, "italic", commitTime.Add(time.Second)) + require.NoError(t, err) + + spans, err := text.SpansAt(ctx, []automerge.Hash{heads}) + require.NoError(t, err) + result[engine.name] = spans + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 2) + assert.Equal(t, map[string]any{"bold": true}, result["native"][0].Marks) +} diff --git a/pkg/automerge/js_marks_parity_test.go b/pkg/automerge/js_marks_parity_test.go new file mode 100644 index 0000000000..07e9fc3324 --- /dev/null +++ b/pkg/automerge/js_marks_parity_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the mark-in-patch behaviors from the upstream +// JavaScript mark suite (javascript/test/marks.ts), asserting the native Go and +// Rust/WASM reference engines report identical Mark patches through the diff +// cursor. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestJSMarks_MarksSeenInPatches reproduces "should allow marks that can be seen +// in patches": marking and then partially unmarking each emit a single Mark +// patch through the diff cursor, the unmark reporting its literal range with a +// null value rather than the resulting split. +func TestJSMarks_MarksSeenInPatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + markPatches := make(map[string][]automerge.Patch) + unmarkPatches := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "the quick fox jumps over the lazy dog") + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, text.Mark(ctx, 5, 10, "font-weight", markStr("bold"), automerge.MarkExpandNone)) + _, err := document.Commit(ctx, "mark", commitTime.Add(time.Second)) + require.NoError(t, err) + marked, err := document.DiffIncremental(ctx) + require.NoError(t, err) + markPatches[engine.name] = marked + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, text.Unmark(ctx, 7, 9, "font-weight", automerge.MarkExpandNone)) + _, err = document.Commit(ctx, "unmark", commitTime.Add(2*time.Second)) + require.NoError(t, err) + unmarked, err := document.DiffIncremental(ctx) + require.NoError(t, err) + unmarkPatches[engine.name] = unmarked + } + + require.Len(t, markPatches["reference"], 1) + assert.Equal(t, automerge.PatchMark, markPatches["reference"][0].Action) + require.Len(t, markPatches["reference"][0].Marks, 1) + assert.Equal(t, uint32(5), markPatches["reference"][0].Marks[0].Start) + assert.Equal(t, uint32(10), markPatches["reference"][0].Marks[0].End) + assert.Equal(t, "bold", markPatches["reference"][0].Marks[0].Value.String) + assert.Equal(t, markPatches["reference"], markPatches["native"]) + + require.Len(t, unmarkPatches["reference"], 1) + require.Len(t, unmarkPatches["reference"][0].Marks, 1) + assert.Equal(t, uint32(7), unmarkPatches["reference"][0].Marks[0].Start) + assert.Equal(t, uint32(9), unmarkPatches["reference"][0].Marks[0].End) + assert.Equal(t, automerge.ScalarTypeNull, unmarkPatches["reference"][0].Marks[0].Value.Type) + assert.Equal(t, unmarkPatches["reference"], unmarkPatches["native"]) +} diff --git a/pkg/automerge/js_text_parity_test.go b/pkg/automerge/js_text_parity_test.go new file mode 100644 index 0000000000..1fde769131 --- /dev/null +++ b/pkg/automerge/js_text_parity_test.go @@ -0,0 +1,210 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the cross-engine text behaviors from the +// upstream JavaScript suite (javascript/test/text_test.ts). Each runs on the +// native Go engine and the Rust/WASM reference engine and asserts they agree. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func hydratedFromEngines() []struct { + name string + from func(context.Context, automerge.ActorID, map[string]automerge.Value, string) (*automerge.Document, error) +} { + return []struct { + name string + from func(context.Context, automerge.ActorID, map[string]automerge.Value, string) (*automerge.Document, error) + }{ + {"native", func(ctx context.Context, actorID automerge.ActorID, value map[string]automerge.Value, message string) (*automerge.Document, error) { + return automerge.NewFrom(ctx, actorID, value, message, commitTime) + }}, + {"reference", func(ctx context.Context, actorID automerge.ActorID, value map[string]automerge.Value, message string) (*automerge.Document, error) { + return automerge.NewReferenceFrom(ctx, actorID, value, message, commitTime) + }}, + } +} + +// TestJSText_ImplicitAndExplicitDeletion reproduces the "implicit and explicit +// deletion" case: a delete splice removes a character and a zero-length splice +// is a no-op. +func TestJSText_ImplicitAndExplicitDeletion(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string]string) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "abc") + require.NoError(t, text.Splice(ctx, 1, 1, "")) + require.NoError(t, text.Splice(ctx, 1, 0, "")) + _, err := document.Commit(ctx, "edit", commitTime) + require.NoError(t, err) + + value, err := text.String(ctx) + require.NoError(t, err) + result[engine.name] = value + } + + assert.Equal(t, "ac", result["reference"]) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestJSText_TextAndOtherOpsSameChange reproduces "text and other ops in the +// same change": a scalar put and a text splice committed together both apply. +func TestJSText_TextAndOtherOpsSameChange(t *testing.T) { + t.Parallel() + + ctx := context.Background() + foos := make(map[string]string) + texts := make(map[string]string) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "") + require.NoError(t, document.Root().PutScalar( + ctx, + "foo", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "bar"}, + )) + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + _, err := document.Commit(ctx, "mixed", commitTime) + require.NoError(t, err) + + foo, err := document.Root().Scalar(ctx, "foo") + require.NoError(t, err) + foos[engine.name] = foo.String + + value, err := text.String(ctx) + require.NoError(t, err) + texts[engine.name] = value + } + + assert.Equal(t, "bar", foos["reference"]) + assert.Equal(t, "a", texts["reference"]) + assert.Equal(t, foos["reference"], foos["native"]) + assert.Equal(t, texts["reference"], texts["native"]) +} + +// TestJSText_InitializeTextInFrom reproduces "initialize text in +// Automerge.from()" and "encode the initial value as a change": a hydrated text +// value round-trips through save/load as a single change. +func TestJSText_InitializeTextInFrom(t *testing.T) { + t.Parallel() + + ctx := context.Background() + loaded := make(map[string]string) + changes := make(map[string]uint64) + + root := map[string]automerge.Value{ + "text": {Type: automerge.ValueTypeText, Text: "init"}, + } + + for _, engine := range hydratedFromEngines() { + document, err := engine.from(ctx, actor(0xaa), root, "init") + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.Text(ctx, "text") + require.NoError(t, err) + + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "init", value) + + stats, err := document.Stats(ctx) + require.NoError(t, err) + changes[engine.name] = stats.NumChanges + + saved, err := document.Save(ctx) + require.NoError(t, err) + + load := automerge.Load + if engine.name == "reference" { + load = automerge.LoadReference + } + + reloaded, err := load(ctx, saved, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, reloaded) + + reloadedText, err := reloaded.Text(ctx, "text") + require.NoError(t, err) + loaded[engine.name], err = reloadedText.String(ctx) + require.NoError(t, err) + } + + assert.Equal(t, "init", loaded["reference"]) + assert.Equal(t, loaded["reference"], loaded["native"]) + assert.Equal(t, uint64(1), changes["reference"]) + assert.Equal(t, changes["reference"], changes["native"]) +} + +// TestJSText_SplicingIntoArrays reproduces "splicing into text in arrays": text +// nested inside lists can be spliced by descending into the nested objects. +func TestJSText_SplicingIntoArrays(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string]string) + + root := map[string]automerge.Value{ + "dom": { + Type: automerge.ValueTypeList, + List: []automerge.Value{{ + Type: automerge.ValueTypeList, + List: []automerge.Value{{Type: automerge.ValueTypeText, Text: "world"}}, + }}, + }, + } + + for _, engine := range hydratedFromEngines() { + document, err := engine.from(ctx, actor(0xaa), root, "init") + require.NoError(t, err) + closeDocument(t, document) + + outer, err := document.Root().Object(ctx, "dom") + require.NoError(t, err) + inner, err := outer.ObjectAt(ctx, 0) + require.NoError(t, err) + textObject, err := inner.ObjectAt(ctx, 0) + require.NoError(t, err) + text, err := textObject.Text() + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 0, 0, "Hello ")) + _, err = document.Commit(ctx, "splice", commitTime) + require.NoError(t, err) + + value, err := text.String(ctx) + require.NoError(t, err) + result[engine.name] = value + } + + assert.Equal(t, "Hello world", result["reference"]) + assert.Equal(t, result["reference"], result["native"]) +} diff --git a/pkg/automerge/list_range_parity_test.go b/pkg/automerge/list_range_parity_test.go new file mode 100644 index 0000000000..33e159b049 --- /dev/null +++ b/pkg/automerge/list_range_parity_test.go @@ -0,0 +1,202 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the list-range behaviors from upstream Rust +// automerge 0.10 (rust/automerge/src/iter/list_range.rs), asserting the native +// Go and Rust/WASM reference engines expose the same list values and per-element +// conflict flags. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestRust_ReproduceClockCacheBug reproduces reproduce_clock_cache_bug: after +// merging many branches authored by distinct actors, no change lies outside the +// merged frontier, so ChangesSince(heads) is empty. A clock-caching defect would +// omit some ancestors and report spurious changes. +func TestRust_ReproduceClockCacheBug(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + base, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, base) + + for i := 0; i < 20; i++ { + require.NoError(t, base.Root().PutScalar( + ctx, + "initial_commit", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i)}, + )) + _, err := base.Commit(ctx, "initial", commitTime.Add(time.Duration(i))) + require.NoError(t, err) + } + + const branches = 20 + + for branch := 0; branch < branches; branch++ { + fork, err := base.Fork(ctx, actor(byte(30+branch))) + require.NoError(t, err) + closeDocument(t, fork) + + for commit := 0; commit < 2; commit++ { + require.NoError(t, fork.Root().PutScalar( + ctx, + "branch_value", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(branch*10 + commit)}, + )) + _, err := fork.Commit(ctx, "branch", commitTime.Add(time.Duration(branch*10+commit))) + require.NoError(t, err) + } + + _, err = base.Merge(ctx, fork) + require.NoError(t, err) + } + + heads, err := base.Heads(ctx) + require.NoError(t, err) + + changes, err := base.ChangesSince(ctx, heads) + require.NoError(t, err) + assert.Empty(t, changes) +} + +// TestRustListRange_Bounds reproduces list_range_bounds: reading the list yields +// its values in order. +func TestRustListRange_Bounds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]int64) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + + for index, value := range []int64{1, 2, 3, 4, 5} { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: value}, + )) + } + + _, err = document.Commit(ctx, "list", commitTime) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + + values := make([]int64, 0, length) + for index := uint64(0); index < length; index++ { + scalar, err := list.ScalarAt(ctx, index) + require.NoError(t, err) + values = append(values, scalar.Int) + } + + result[engine.name] = values + } + + assert.Equal(t, []int64{1, 2, 3, 4, 5}, result["reference"]) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustListRange_Conflict reproduces list_range_conflict: a concurrently +// overwritten element is reported as conflicted with the winning value. +func TestRustListRange_Conflict(t *testing.T) { + t.Parallel() + + ctx := context.Background() + values := make(map[string][]int64) + conflicts := make(map[string][]bool) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + list, err := document.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + + for index, value := range []int64{1, 2, 3, 4, 5} { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: value}, + )) + } + + _, err = document.Commit(ctx, "list", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, other) + + otherList, err := other.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, otherList.PutScalarAt(ctx, 3, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 11})) + _, err = other.Commit(ctx, "other", commitTime.Add(1)) + require.NoError(t, err) + + require.NoError(t, list.PutScalarAt(ctx, 3, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 10})) + _, err = document.Commit(ctx, "mine", commitTime.Add(1)) + require.NoError(t, err) + + _, err = other.Merge(ctx, document) + require.NoError(t, err) + + length, err := otherList.Len(ctx) + require.NoError(t, err) + + rowValues := make([]int64, 0, length) + rowConflicts := make([]bool, 0, length) + + for index := uint64(0); index < length; index++ { + scalar, err := otherList.ScalarAt(ctx, index) + require.NoError(t, err) + rowValues = append(rowValues, scalar.Int) + + all, err := otherList.ScalarsAt(ctx, index) + require.NoError(t, err) + rowConflicts = append(rowConflicts, len(all) > 1) + } + + values[engine.name] = rowValues + conflicts[engine.name] = rowConflicts + } + + assert.Equal(t, []bool{false, false, false, true, false}, conflicts["reference"]) + assert.Equal(t, values["reference"], values["native"]) + assert.Equal(t, conflicts["reference"], conflicts["native"]) +} diff --git a/pkg/automerge/marks_property_parity_test.go b/pkg/automerge/marks_property_parity_test.go new file mode 100644 index 0000000000..86bb585510 --- /dev/null +++ b/pkg/automerge/marks_property_parity_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file reproduces the upstream marks_are_okay property test +// (rust/automerge/tests/text.rs). Like the upstream proptest, many random +// sequences of insert, delete, split-block, and mark operations are applied and +// the resulting spans must (1) stay consolidated (no two adjacent text spans +// carry an identical mark set) and (2) reproduce the accumulated text. The +// upstream test asserts these structural invariants rather than specific mark +// values or a differential against another engine, so both the native Go and +// Rust/WASM reference engines are checked against the same invariants here. + +package automerge_test + +import ( + "context" + "math/rand" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func marksAreConsolidated(spans []automerge.Span) bool { + haveLast := false + + var last map[string]any + + for _, span := range spans { + if span.Type != automerge.SpanTypeText { + haveLast = false + + continue + } + + if haveLast && sameMarkSet(last, span.Marks) { + return false + } + + last = span.Marks + haveLast = true + } + + return true +} + +func sameMarkSet(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + + for key, value := range a { + other, ok := b[key] + if !ok || other != value { + return false + } + } + + return true +} + +// TestRustText_MarksAreOkay reproduces marks_are_okay across randomized scenarios. +func TestRustText_MarksAreOkay(t *testing.T) { + t.Parallel() + + ctx := context.Background() + random := rand.New(rand.NewSource(0x2545f4914f6cdd1d)) + + const scenarios = 300 + + markNames := []string{"bold", "italic", "underline"} + + for scenario := 0; scenario < scenarios; scenario++ { + engineSpans := make(map[string][]automerge.Span) + + var expected []rune + + steps := 3 + random.Intn(18) + actions := make([]func(context.Context, *testing.T, *automerge.Text), 0, steps) + + length := 0 + + for step := 0; step < steps; step++ { + switch random.Intn(4) { + case 0: // insert + index := random.Intn(length + 1) + value := randomLetters(random, 1+random.Intn(6)) + runes := []rune(value) + + actions = append(actions, func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, uint32(index), 0, value)) + }) + + expected = append(expected[:index], append(append([]rune{}, runes...), expected[index:]...)...) + length += len(runes) + case 1: // delete + if length == 0 { + continue + } + + deleteLen := 1 + random.Intn(length) + index := random.Intn(length - deleteLen + 1) + + actions = append(actions, func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, uint32(index), int32(deleteLen), "")) + }) + + expected = append(expected[:index], expected[index+deleteLen:]...) + length -= deleteLen + case 2: // split block + index := random.Intn(length + 1) + + actions = append(actions, func(ctx context.Context, t *testing.T, text *automerge.Text) { + _, err := text.SplitBlock(ctx, uint32(index)) + require.NoError(t, err) + }) + + expected = append(expected[:index], append([]rune{'\n'}, expected[index:]...)...) + length++ + case 3: // add mark + if length == 0 { + continue + } + + markLen := 1 + random.Intn(length) + index := random.Intn(length - markLen + 1) + name := markNames[random.Intn(len(markNames))] + + actions = append(actions, func(ctx context.Context, t *testing.T, text *automerge.Text) { + require.NoError(t, text.Mark( + ctx, + uint32(index), + uint32(index+markLen), + name, + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + }) + } + } + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + for _, action := range actions { + action(ctx, t, text) + } + + _, err = document.Commit(ctx, "scenario", commitTime) + require.NoError(t, err) + + spans, err := text.Spans(ctx) + require.NoError(t, err) + engineSpans[engine.name] = spans + } + + for _, engine := range rustParityEngines() { + spans := engineSpans[engine.name] + + require.True( + t, + marksAreConsolidated(spans), + "scenario %d marks not consolidated on %s: %+v", + scenario, engine.name, spans, + ) + + var builder strings.Builder + for _, span := range spans { + if span.Type == automerge.SpanTypeBlock { + builder.WriteRune('\n') + + continue + } + + builder.WriteString(span.Text) + } + + require.Equal( + t, + string(expected), + builder.String(), + "scenario %d span text diverged on %s", + scenario, engine.name, + ) + } + } +} + +func randomLetters(random *rand.Rand, count int) string { + const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + var builder strings.Builder + + for i := 0; i < count; i++ { + builder.WriteByte(alphabet[random.Intn(len(alphabet))]) + } + + return builder.String() +} diff --git a/pkg/automerge/metadata_test.go b/pkg/automerge/metadata_test.go new file mode 100644 index 0000000000..4bbd8d533c --- /dev/null +++ b/pkg/automerge/metadata_test.go @@ -0,0 +1,347 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/internal/native" +) + +// TestDocument_StatsMatchReference reproduces stats_smoke_test. +func TestDocument_StatsMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + document, err := factory(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "a", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + _, err = document.Commit(ctx, "a", commitTime) + require.NoError(t, err) + + require.NoError(t, document.Root().PutScalar( + ctx, + "b", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + _, err = document.Commit(ctx, "b", commitTime.Add(time.Second)) + require.NoError(t, err) + + stats, err := document.Stats(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), stats.NumChanges) + assert.Equal(t, uint64(2), stats.NumOps) + assert.Equal(t, uint64(1), stats.NumActors) + }) + } +} + +func TestDocument_CommitTimeParity(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(149)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.PutString(ctx, "zero", "value")) + _, err = document.Commit(ctx, "zero", time.Time{}) + require.NoError(t, err) + require.NoError(t, document.PutString(ctx, "provided", "value")) + _, err = document.Commit( + ctx, + "provided", + time.Unix(12_345, 0), + ) + require.NoError(t, err) + require.NoError(t, document.PutString(ctx, "current", "value")) + + before := time.Now().Unix() + _, err = document.CommitNow(ctx, "current") + after := time.Now().Unix() + + require.NoError(t, err) + + data, err := document.Save(ctx) + require.NoError(t, err) + decoded, err := native.Decode(data) + require.NoError(t, err) + require.Len(t, decoded.Changes, 3) + assert.Equal(t, int64(0), decoded.Changes[0].Time) + assert.Equal(t, int64(12_345), decoded.Changes[1].Time) + assert.GreaterOrEqual(t, decoded.Changes[2].Time, before) + assert.LessOrEqual(t, decoded.Changes[2].Time, after) + }) + } +} + +func TestDocument_EmptyCommitTimeParity(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(150)) + require.NoError(t, err) + closeDocument(t, document) + + _, err = document.EmptyCommit(ctx, "zero", time.Time{}) + require.NoError(t, err) + _, err = document.EmptyCommit( + ctx, + "provided", + time.Unix(12_345, 0), + ) + require.NoError(t, err) + + before := time.Now().Unix() + _, err = document.EmptyCommitNow(ctx, "current") + after := time.Now().Unix() + + require.NoError(t, err) + + data, err := document.Save(ctx) + require.NoError(t, err) + decoded, err := native.Decode(data) + require.NoError(t, err) + require.Len(t, decoded.Changes, 3) + + for index, change := range decoded.Changes { + assert.Equal(t, uint64(index+1), change.Sequence) + assert.Equal(t, uint64(1), change.StartOp) + assert.Equal(t, uint64(0), change.MaxOp) + assert.Empty(t, change.Operations) + } + + assert.Equal(t, int64(0), decoded.Changes[0].Time) + assert.Equal(t, int64(12_345), decoded.Changes[1].Time) + assert.GreaterOrEqual(t, decoded.Changes[2].Time, before) + assert.LessOrEqual(t, decoded.Changes[2].Time, after) + }) + } +} + +func TestDocument_EmptyCommitChangesSince(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(156)) + require.NoError(t, err) + closeDocument(t, document) + hash, err := document.EmptyCommit(ctx, "empty", time.Time{}) + require.NoError(t, err) + + changes, err := document.ChangesSince(ctx, nil) + require.NoError(t, err) + require.Len(t, changes, 1) + assert.Equal(t, hash, changes[0].Hash) + changes, err = document.ChangesSince(ctx, []automerge.Hash{hash}) + require.NoError(t, err) + assert.Empty(t, changes) + + data, err := document.Save(ctx) + require.NoError(t, err) + reference, err := automerge.LoadReference(ctx, data, actor(157)) + require.NoError(t, err) + closeDocument(t, reference) + heads, err := reference.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, []automerge.Hash{hash}, heads) +} + +func TestDocument_HistoricalReadsMatchReference(t *testing.T) { + t.Parallel() + + factories := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range factories { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(158)) + require.NoError(t, err) + closeDocument(t, document) + root := document.Root() + require.NoError(t, root.PutScalar( + ctx, + "value", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "A")) + first, err := document.Commit(ctx, "first", commitTime) + require.NoError(t, err) + + require.NoError(t, root.PutScalar( + ctx, + "value", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + require.NoError(t, text.Splice(ctx, 1, 0, "B")) + second, err := document.Commit( + ctx, + "second", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + + historicalScalar, err := root.ScalarAtHeads( + ctx, + "value", + []automerge.Hash{first}, + ) + require.NoError(t, err) + assert.Equal(t, int64(1), historicalScalar.Int) + + currentScalar, err := root.Scalar(ctx, "value") + require.NoError(t, err) + assert.Equal(t, int64(2), currentScalar.Int) + + historicalText, err := text.StringAt( + ctx, + []automerge.Hash{first}, + ) + require.NoError(t, err) + assert.Equal(t, "A", historicalText) + + currentText, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AB", currentText) + + hasHeads, err := document.HasHeads( + ctx, + []automerge.Hash{first, second}, + ) + require.NoError(t, err) + assert.True(t, hasHeads) + hasHeads, err = document.HasHeads(ctx, nil) + require.NoError(t, err) + assert.True(t, hasHeads) + + var unknown automerge.Hash + + unknown[0] = 1 + hasHeads, err = document.HasHeads( + ctx, + []automerge.Hash{unknown}, + ) + require.NoError(t, err) + assert.False(t, hasHeads) + }) + } +} + +func TestDocument_MissingDependenciesMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(159)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(159)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + var unknown automerge.Hash + + unknown[0] = 1 + nativeMissing, err := nativeDocument.MissingDependencies( + ctx, + []automerge.Hash{unknown}, + ) + require.NoError(t, err) + referenceMissing, err := referenceDocument.MissingDependencies( + ctx, + []automerge.Hash{unknown}, + ) + require.NoError(t, err) + assert.Equal(t, referenceMissing, nativeMissing) + assert.Equal(t, []automerge.Hash{unknown}, nativeMissing) + + for _, document := range []*automerge.Document{ + nativeDocument, + referenceDocument, + } { + require.NoError(t, document.PutString(ctx, "value", "known")) + hash, err := document.Commit(ctx, "known", commitTime) + require.NoError(t, err) + missing, err := document.MissingDependencies( + ctx, + []automerge.Hash{hash}, + ) + require.NoError(t, err) + assert.Empty(t, missing) + } +} diff --git a/pkg/automerge/native_backend_test.go b/pkg/automerge/native_backend_test.go index 95484a4997..07396356fc 100644 --- a/pkg/automerge/native_backend_test.go +++ b/pkg/automerge/native_backend_test.go @@ -23,6 +23,7 @@ package automerge_test import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -293,6 +294,395 @@ func TestPureGoDocument_DeletedCursorMatchesReference(t *testing.T) { assert.Equal(t, referencePosition, nativePosition) } +func TestPureGoDocument_UTF16CursorBoundariesMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := automerge.NewReference(ctx, actor(122)) + require.NoError(t, err) + closeDocument(t, base) + baseText, err := base.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, baseText.Splice(ctx, 0, 0, "AπŸ˜€B")) + _, err = base.Commit(ctx, "Create emoji text", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + nativeDocument, err := automerge.LoadPureGo(ctx, baseData, actor(123)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, baseData, actor(124)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + + nativeInside, nativeErr := nativeText.Cursor(ctx, 2) + referenceInside, referenceErr := referenceText.Cursor(ctx, 2) + + require.NoError(t, nativeErr) + require.NoError(t, referenceErr) + require.Equal(t, referenceInside, nativeInside) + nativeInsidePosition, err := nativeText.CursorPosition(ctx, nativeInside) + require.NoError(t, err) + referenceInsidePosition, err := referenceText.CursorPosition( + ctx, + referenceInside, + ) + require.NoError(t, err) + assert.Equal(t, referenceInsidePosition, nativeInsidePosition) + + for _, index := range []uint32{1, 3} { + nativeCursor, err := nativeText.Cursor(ctx, index) + require.NoError(t, err) + referenceCursor, err := referenceText.Cursor(ctx, index) + require.NoError(t, err) + require.Equal(t, referenceCursor, nativeCursor) + + require.NoError(t, nativeText.Splice(ctx, 0, 0, "X")) + require.NoError(t, referenceText.Splice(ctx, 0, 0, "X")) + nativePosition, err := nativeText.CursorPosition(ctx, nativeCursor) + require.NoError(t, err) + referencePosition, err := referenceText.CursorPosition( + ctx, + referenceCursor, + ) + require.NoError(t, err) + assert.Equal(t, referencePosition, nativePosition) + + require.NoError(t, nativeText.Splice(ctx, 0, 1, "")) + require.NoError(t, referenceText.Splice(ctx, 0, 1, "")) + } +} + +func TestPureGoDocument_CursorModesMatchReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := automerge.NewReference(ctx, actor(146)) + require.NoError(t, err) + closeDocument(t, base) + baseText, err := base.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, baseText.Splice(ctx, 0, 0, "AπŸ˜€B")) + _, err = base.Commit(ctx, "cursor base", commitTime) + require.NoError(t, err) + data, err := base.Save(ctx) + require.NoError(t, err) + + nativeDocument, err := automerge.Load(ctx, data, actor(147)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + referenceDocument, err := automerge.LoadReference(ctx, data, actor(148)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + + for _, index := range []int64{-1, 0, 1, 2, 3, 4, 100} { + for _, movement := range []automerge.CursorMove{ + automerge.CursorMoveBefore, + automerge.CursorMoveAfter, + } { + nativeCursor, err := nativeText.CursorFor(ctx, index, movement) + require.NoError(t, err) + referenceCursor, err := referenceText.CursorFor( + ctx, + index, + movement, + ) + require.NoError(t, err) + assert.Equal(t, referenceCursor, nativeCursor) + nativePosition, err := nativeText.CursorPosition( + ctx, + nativeCursor, + ) + require.NoError(t, err) + referencePosition, err := referenceText.CursorPosition( + ctx, + referenceCursor, + ) + require.NoError(t, err) + assert.Equal(t, referencePosition, nativePosition) + } + } + + nativeCursor, err := nativeText.CursorFor( + ctx, + 1, + automerge.CursorMoveAfter, + ) + require.NoError(t, err) + referenceCursor, err := referenceText.CursorFor( + ctx, + 1, + automerge.CursorMoveAfter, + ) + require.NoError(t, err) + nativeBefore, err := nativeText.CursorFor( + ctx, + 1, + automerge.CursorMoveBefore, + ) + require.NoError(t, err) + referenceBefore, err := referenceText.CursorFor( + ctx, + 1, + automerge.CursorMoveBefore, + ) + require.NoError(t, err) + require.NoError(t, nativeText.SpliceCursor(ctx, nativeCursor, 2, "X")) + require.NoError(t, referenceText.SpliceCursor(ctx, referenceCursor, 2, "X")) + nativeValue, err := nativeText.String(ctx) + require.NoError(t, err) + referenceValue, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, referenceValue, nativeValue) + assert.Equal(t, "AXB", nativeValue) + + for _, cursors := range [][2]automerge.Cursor{ + {nativeBefore, referenceBefore}, + {nativeCursor, referenceCursor}, + } { + nativePosition, err := nativeText.CursorPosition(ctx, cursors[0]) + require.NoError(t, err) + referencePosition, err := referenceText.CursorPosition(ctx, cursors[1]) + require.NoError(t, err) + assert.Equal(t, referencePosition, nativePosition) + } +} + +func TestPureGoDocument_MarkAuthoringMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(153)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(153)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeText, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + referenceText, err := referenceDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, nativeText.Splice(ctx, 0, 0, "ABCD")) + require.NoError(t, referenceText.Splice(ctx, 0, 0, "ABCD")) + _, err = nativeDocument.Commit(ctx, "create text", commitTime) + require.NoError(t, err) + _, err = referenceDocument.Commit(ctx, "create text", commitTime) + require.NoError(t, err) + + strong := automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true} + require.NoError(t, nativeText.Mark( + ctx, + 0, + 4, + "strong", + strong, + automerge.MarkExpandBoth, + )) + require.NoError(t, referenceText.Mark( + ctx, + 0, + 4, + "strong", + strong, + automerge.MarkExpandBoth, + )) + _, err = nativeDocument.Commit(ctx, "mark", commitTime.Add(time.Second)) + require.NoError(t, err) + _, err = referenceDocument.Commit(ctx, "mark", commitTime.Add(time.Second)) + require.NoError(t, err) + nativeMarkedSpans, err := nativeText.Spans(ctx) + require.NoError(t, err) + referenceMarkedSpans, err := referenceText.Spans(ctx) + require.NoError(t, err) + assert.Equal(t, referenceMarkedSpans, nativeMarkedSpans) + + require.NoError(t, nativeText.Unmark( + ctx, + 1, + 3, + "strong", + automerge.MarkExpandNone, + )) + require.NoError(t, referenceText.Unmark( + ctx, + 1, + 3, + "strong", + automerge.MarkExpandNone, + )) + _, err = nativeDocument.Commit(ctx, "unmark", commitTime.Add(2*time.Second)) + require.NoError(t, err) + _, err = referenceDocument.Commit( + ctx, + "unmark", + commitTime.Add(2*time.Second), + ) + require.NoError(t, err) + + nativeSpans, err := nativeText.Spans(ctx) + require.NoError(t, err) + referenceSpans, err := referenceText.Spans(ctx) + require.NoError(t, err) + assert.Equal(t, referenceSpans, nativeSpans) + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(154), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + referenceFromNativeText, err := referenceFromNative.Text(ctx, "body") + require.NoError(t, err) + referenceFromNativeSpans, err := referenceFromNativeText.Spans(ctx) + require.NoError(t, err) + assert.Equal(t, nativeSpans, referenceFromNativeSpans) + + referenceData, err := referenceDocument.Save(ctx) + require.NoError(t, err) + nativeFromReference, err := automerge.Load( + ctx, + referenceData, + actor(155), + ) + require.NoError(t, err) + closeDocument(t, nativeFromReference) + nativeFromReferenceText, err := nativeFromReference.Text(ctx, "body") + require.NoError(t, err) + nativeFromReferenceSpans, err := nativeFromReferenceText.Spans(ctx) + require.NoError(t, err) + assert.Equal(t, referenceSpans, nativeFromReferenceSpans) +} + +func TestPureGoDocument_BlockAuthoringMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(167)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.NewReference(ctx, actor(167)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeText, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + referenceText, err := referenceDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, nativeText.Splice(ctx, 0, 0, "AB")) + require.NoError(t, referenceText.Splice(ctx, 0, 0, "AB")) + + nativeFirst, err := nativeText.SplitBlock(ctx, 0) + require.NoError(t, err) + referenceFirst, err := referenceText.SplitBlock(ctx, 0) + require.NoError(t, err) + setBlockAttributes(t, ctx, nativeFirst, "paragraph") + setBlockAttributes(t, ctx, referenceFirst, "paragraph") + nativeSecond, err := nativeText.SplitBlock(ctx, 2) + require.NoError(t, err) + referenceSecond, err := referenceText.SplitBlock(ctx, 2) + require.NoError(t, err) + setBlockAttributes(t, ctx, nativeSecond, "heading") + setBlockAttributes(t, ctx, referenceSecond, "heading") + _, err = nativeDocument.Commit(ctx, "blocks", commitTime) + require.NoError(t, err) + _, err = referenceDocument.Commit(ctx, "blocks", commitTime) + require.NoError(t, err) + assertTextSpansEqual(t, ctx, nativeText, referenceText) + + nativeReplacement, err := nativeText.ReplaceBlock(ctx, 2) + require.NoError(t, err) + referenceReplacement, err := referenceText.ReplaceBlock(ctx, 2) + require.NoError(t, err) + setBlockAttributes(t, ctx, nativeReplacement, "blockquote") + setBlockAttributes(t, ctx, referenceReplacement, "blockquote") + require.NoError(t, nativeText.JoinBlock(ctx, 0)) + require.NoError(t, referenceText.JoinBlock(ctx, 0)) + _, err = nativeDocument.Commit( + ctx, + "replace and join", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + _, err = referenceDocument.Commit( + ctx, + "replace and join", + commitTime.Add(time.Second), + ) + require.NoError(t, err) + assertTextSpansEqual(t, ctx, nativeText, referenceText) + + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(168), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + referenceFromNativeText, err := referenceFromNative.Text(ctx, "body") + require.NoError(t, err) + assertTextSpansEqual(t, ctx, nativeText, referenceFromNativeText) +} + +func setBlockAttributes( + t *testing.T, + ctx context.Context, + block *automerge.Object, + blockType string, +) { + t.Helper() + + require.NoError(t, block.PutScalar( + ctx, + "type", + automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: blockType, + }, + )) + _, err := block.CreateObject(ctx, "parents", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = block.CreateObject(ctx, "attrs", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, block.PutScalar( + ctx, + "isEmbed", + automerge.Scalar{Type: automerge.ScalarTypeBoolean}, + )) +} + +func assertTextSpansEqual( + t *testing.T, + ctx context.Context, + left *automerge.Text, + right *automerge.Text, +) { + t.Helper() + + leftSpans, err := left.Spans(ctx) + require.NoError(t, err) + rightSpans, err := right.Spans(ctx) + require.NoError(t, err) + assert.Equal(t, rightSpans, leftSpans) +} + func TestPureGoDocument_SynchronizesWithNativePeer(t *testing.T) { t.Parallel() @@ -548,3 +938,182 @@ func TestPureGoDocument_ReferenceEditsWhileMessageInFlight(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ABC", value) } + +func TestSyncState_ReadOnlyParity(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + sourceReference bool + }{ + "native publisher": {sourceReference: false}, + "reference publisher": {sourceReference: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + var ( + source *automerge.Document + target *automerge.Document + err error + ) + if test.sourceReference { + source, err = automerge.NewReference(ctx, actor(181)) + require.NoError(t, err) + target, err = automerge.New(ctx, actor(182)) + } else { + source, err = automerge.New(ctx, actor(181)) + require.NoError(t, err) + target, err = automerge.NewReference(ctx, actor(182)) + } + + require.NoError(t, err) + closeDocument(t, source) + closeDocument(t, target) + + text, err := source.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Published")) + _, err = source.Commit(ctx, "publish", commitTime) + require.NoError(t, err) + + sourceState, err := source.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, sourceState) + + targetState, err := target.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, targetState) + require.NoError(t, targetState.SetReadOnly(ctx, true)) + + synchronize(t, sourceState, targetState) + + peerReadOnly, err := sourceState.PeerReadOnly(ctx) + require.NoError(t, err) + assert.True(t, peerReadOnly) + + _, err = target.Text(ctx, "body") + require.Error(t, err) + + require.NoError(t, targetState.SetReadOnly(ctx, false)) + synchronize(t, sourceState, targetState) + + peerReadOnly, err = sourceState.PeerReadOnly(ctx) + require.NoError(t, err) + assert.False(t, peerReadOnly) + + targetText, err := target.Text(ctx, "body") + require.NoError(t, err) + value, err := targetText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Published", value) + }) + } +} + +func TestSyncState_ReadOnlyModeOverridesInFlight(t *testing.T) { + t.Parallel() + + tests := map[string]func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error){ + "native": automerge.New, + "reference": automerge.NewReference, + } + + for name, factory := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := factory(ctx, actor(183)) + require.NoError(t, err) + closeDocument(t, document) + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "A")) + _, err = document.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + state, err := document.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, state) + + _, ok, err := state.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, state.SetReadOnly(ctx, true)) + _, ok, err = state.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, state.SetReadOnly(ctx, false)) + _, ok, err = state.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + }) + } +} + +func TestSyncState_BothReadOnlyResumeConvergence(t *testing.T) { + t.Parallel() + + ctx := context.Background() + left, err := automerge.New(ctx, actor(184)) + require.NoError(t, err) + closeDocument(t, left) + leftText, err := left.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, leftText.Splice(ctx, 0, 0, "L")) + _, err = left.Commit(ctx, "left", commitTime) + require.NoError(t, err) + + right, err := automerge.NewReference(ctx, actor(185)) + require.NoError(t, err) + closeDocument(t, right) + rightText, err := right.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, rightText.Splice(ctx, 0, 0, "R")) + _, err = right.Commit(ctx, "right", commitTime) + require.NoError(t, err) + + leftState, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftState) + + rightState, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightState) + require.NoError(t, leftState.SetReadOnly(ctx, true)) + require.NoError(t, rightState.SetReadOnly(ctx, true)) + synchronize(t, leftState, rightState) + + leftValue, err := leftText.String(ctx) + require.NoError(t, err) + rightValue, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "L", leftValue) + assert.Equal(t, "R", rightValue) + + require.NoError(t, leftState.SetReadOnly(ctx, false)) + require.NoError(t, rightState.SetReadOnly(ctx, false)) + synchronize(t, leftState, rightState) + + leftHeads, err := left.Heads(ctx) + require.NoError(t, err) + rightHeads, err := right.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, leftHeads, rightHeads) + + leftText, err = left.Text(ctx, "body") + require.NoError(t, err) + leftValue, err = leftText.String(ctx) + require.NoError(t, err) + rightText, err = right.Text(ctx, "body") + require.NoError(t, err) + rightValue, err = rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, leftValue, rightValue) +} diff --git a/pkg/automerge/native_differential_test.go b/pkg/automerge/native_differential_test.go index 2b585dc192..240c27b273 100644 --- a/pkg/automerge/native_differential_test.go +++ b/pkg/automerge/native_differential_test.go @@ -25,6 +25,7 @@ import ( "fmt" "math/rand" "testing" + "time" "unicode/utf16" "github.com/stretchr/testify/assert" @@ -113,6 +114,144 @@ func TestPureGoDocument_RandomTextParity(t *testing.T) { } } +func TestPureGoDocument_RandomConcurrentSyncParity(t *testing.T) { + t.Parallel() + + const ( + histories = 10 + rounds = 20 + ) + + ctx := context.Background() + characters := []rune("abcXYZπŸ˜€Γ©") + + for history := range histories { + random := rand.New(rand.NewSource(int64(10_000 + history))) + nativeDocument, err := automerge.NewPureGo( + ctx, + actor(byte(140+history)), + ) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + _, err = nativeDocument.Commit(ctx, "create body", commitTime) + require.NoError(t, err) + + referenceDocument, err := automerge.NewReference( + ctx, + actor(byte(160+history)), + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + nativeSync, err := nativeDocument.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, nativeSync) + + referenceSync, err := referenceDocument.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, referenceSync) + synchronize(t, nativeSync, referenceSync) + + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + + for round := range rounds { + randomTextMutation( + t, + ctx, + random, + nativeText, + characters, + ) + _, err = nativeDocument.Commit( + ctx, + fmt.Sprintf("native history %d round %d", history, round), + commitTime.Add(time.Duration(round+1)*time.Second), + ) + require.NoError(t, err) + + randomTextMutation( + t, + ctx, + random, + referenceText, + characters, + ) + _, err = referenceDocument.Commit( + ctx, + fmt.Sprintf("reference history %d round %d", history, round), + commitTime.Add(time.Duration(round+1)*time.Second), + ) + require.NoError(t, err) + + synchronize(t, nativeSync, referenceSync) + + nativeValue, err := nativeText.String(ctx) + require.NoError(t, err) + referenceValue, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal( + t, + referenceValue, + nativeValue, + "history %d round %d", + history, + round, + ) + + nativeHeads, err := nativeDocument.Heads(ctx) + require.NoError(t, err) + referenceHeads, err := referenceDocument.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, referenceHeads, nativeHeads) + } + } +} + +func randomTextMutation( + t *testing.T, + ctx context.Context, + random *rand.Rand, + text *automerge.Text, + characters []rune, +) { + t.Helper() + + value, err := text.String(ctx) + require.NoError(t, err) + + runes := []rune(value) + offsets := utf16Offsets(runes) + + if len(runes) > 0 && random.Intn(3) == 0 { + position := random.Intn(len(runes)) + require.NoError( + t, + text.Splice( + ctx, + offsets[position], + int32(offsets[position+1]-offsets[position]), + "", + ), + ) + + return + } + + position := random.Intn(len(runes) + 1) + require.NoError( + t, + text.Splice( + ctx, + offsets[position], + 0, + string(characters[random.Intn(len(characters))]), + ), + ) +} + func utf16Offsets(value []rune) []uint32 { offsets := make([]uint32, len(value)+1) for i, character := range value { diff --git a/pkg/automerge/object.go b/pkg/automerge/object.go new file mode 100644 index 0000000000..a2c9c06b3e --- /dev/null +++ b/pkg/automerge/object.go @@ -0,0 +1,559 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge + +import ( + "context" + "fmt" +) + +type ( + // ObjectType identifies an Automerge composite value. + ObjectType string + + // Object is a map, list, text, or table inside a document. + Object struct { + document *Document + handle uint32 + Type ObjectType + } +) + +const ( + ObjectTypeMap ObjectType = "map" + ObjectTypeList ObjectType = "list" + ObjectTypeText ObjectType = "text" + ObjectTypeTable ObjectType = "table" +) + +// Root returns the document's root map. +func (d *Document) Root() *Object { + return &Object{ + document: d, + handle: rootObject, + Type: ObjectTypeMap, + } +} + +// CreateObject assigns a new composite value to a map property. +func (o *Object) CreateObject( + ctx context.Context, + key string, + objectType ObjectType, +) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + handle, err := o.document.backend.PutObject( + ctx, + o.handle, + key, + string(objectType), + ) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge object: %w", err) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} + +// Object returns a composite value from a map property. +func (o *Object) Object(ctx context.Context, key string) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + handle, rawType, err := o.document.backend.GetObject(ctx, o.handle, key) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge object: %w", err) + } + + objectType := ObjectType(rawType) + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} + +// PutScalar assigns a typed scalar to a map property. +func (o *Object) PutScalar(ctx context.Context, key string, value Scalar) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode Automerge scalar: %w", err) + } + + if err := o.document.backend.PutScalar(ctx, o.handle, key, encoded); err != nil { + return fmt.Errorf("cannot put Automerge scalar: %w", err) + } + + return nil +} + +// Scalar returns a typed scalar from a map property. +func (o *Object) Scalar(ctx context.Context, key string) (Scalar, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return Scalar{}, ErrClosed + } + + encoded, err := o.document.backend.GetScalar(ctx, o.handle, key) + if err != nil { + return Scalar{}, fmt.Errorf("cannot get Automerge scalar: %w", err) + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode Automerge scalar: %w", err) + } + + return value, nil +} + +// ScalarAtHeads returns a map scalar at a historical causal frontier. +func (o *Object) ScalarAtHeads( + ctx context.Context, + key string, + heads []Hash, +) (Scalar, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return Scalar{}, ErrClosed + } + + encoded, err := o.document.backend.GetScalarAtHeads( + ctx, + o.handle, + key, + backendHashes(heads), + ) + if err != nil { + return Scalar{}, fmt.Errorf("cannot get historical Automerge scalar: %w", err) + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode historical Automerge scalar: %w", err) + } + + return value, nil +} + +// Scalars returns every concurrent scalar value at a map property. +func (o *Object) Scalars(ctx context.Context, key string) ([]Scalar, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + encoded, err := o.document.backend.GetAllScalars( + ctx, + o.handle, + key, + ) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge scalar conflicts: %w", err) + } + + values, err := decodeScalarWires(encoded) + if err != nil { + return nil, fmt.Errorf("cannot decode Automerge scalar conflicts: %w", err) + } + + return values, nil +} + +// ScalarsAt returns every concurrent scalar value at a list index. +func (o *Object) ScalarsAt(ctx context.Context, index uint64) ([]Scalar, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + encoded, err := o.document.backend.GetAllScalarsAt( + ctx, + o.handle, + index, + ) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge sequence scalar conflicts: %w", err) + } + + values, err := decodeScalarWires(encoded) + if err != nil { + return nil, fmt.Errorf("cannot decode Automerge sequence scalar conflicts: %w", err) + } + + return values, nil +} + +// InsertScalar inserts a typed scalar at a list index. +func (o *Object) InsertScalar( + ctx context.Context, + index uint64, + value Scalar, +) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode Automerge scalar: %w", err) + } + + if err := o.document.backend.InsertScalar( + ctx, + o.handle, + index, + encoded, + ); err != nil { + return fmt.Errorf("cannot insert Automerge scalar: %w", err) + } + + return nil +} + +// PutScalarAt replaces a list element with a typed scalar. +func (o *Object) PutScalarAt( + ctx context.Context, + index uint64, + value Scalar, +) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode Automerge scalar: %w", err) + } + + if err := o.document.backend.PutScalarAt( + ctx, + o.handle, + index, + encoded, + ); err != nil { + return fmt.Errorf("cannot replace Automerge scalar: %w", err) + } + + return nil +} + +// InsertObject inserts a new composite value at a list index. +func (o *Object) InsertObject( + ctx context.Context, + index uint64, + objectType ObjectType, +) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + handle, err := o.document.backend.InsertObject( + ctx, + o.handle, + index, + string(objectType), + ) + if err != nil { + return nil, fmt.Errorf("cannot insert Automerge object: %w", err) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} + +// PutObjectAt replaces a list element with a new composite value. +func (o *Object) PutObjectAt( + ctx context.Context, + index uint64, + objectType ObjectType, +) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + handle, err := o.document.backend.PutObjectAt( + ctx, + o.handle, + index, + string(objectType), + ) + if err != nil { + return nil, fmt.Errorf("cannot replace Automerge object: %w", err) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} + +// ScalarAt returns a typed scalar from a list index. +func (o *Object) ScalarAt(ctx context.Context, index uint64) (Scalar, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return Scalar{}, ErrClosed + } + + encoded, err := o.document.backend.GetScalarAt(ctx, o.handle, index) + if err != nil { + return Scalar{}, fmt.Errorf("cannot get Automerge scalar: %w", err) + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode Automerge scalar: %w", err) + } + + return value, nil +} + +// ObjectAt returns a composite value from a list index. +func (o *Object) ObjectAt(ctx context.Context, index uint64) (*Object, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + handle, rawType, err := o.document.backend.GetObjectAt( + ctx, + o.handle, + index, + ) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge object: %w", err) + } + + objectType := ObjectType(rawType) + if !validObjectType(objectType) { + return nil, fmt.Errorf("unknown Automerge object type %q", objectType) + } + + return &Object{ + document: o.document, + handle: handle, + Type: objectType, + }, nil +} + +// DeleteKey deletes a map property. +func (o *Object) DeleteKey(ctx context.Context, key string) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + if err := o.document.backend.DeleteMap(ctx, o.handle, key); err != nil { + return fmt.Errorf("cannot delete Automerge map property: %w", err) + } + + return nil +} + +// DeleteIndex deletes a list element. +func (o *Object) DeleteIndex(ctx context.Context, index uint64) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + if err := o.document.backend.DeleteSequence(ctx, o.handle, index); err != nil { + return fmt.Errorf("cannot delete Automerge sequence element: %w", err) + } + + return nil +} + +// Increment adds delta to a counter stored at a map property. +func (o *Object) Increment(ctx context.Context, key string, delta int64) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + if err := o.document.backend.Increment( + ctx, + o.handle, + key, + delta, + ); err != nil { + return fmt.Errorf("cannot increment Automerge counter: %w", err) + } + + return nil +} + +// IncrementAt adds delta to a counter stored at a list index. +func (o *Object) IncrementAt( + ctx context.Context, + index uint64, + delta int64, +) error { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return ErrClosed + } + + if err := o.document.backend.IncrementAt( + ctx, + o.handle, + index, + delta, + ); err != nil { + return fmt.Errorf("cannot increment Automerge sequence counter: %w", err) + } + + return nil +} + +// Len returns the visible length of a list or text object. +func (o *Object) Len(ctx context.Context) (uint64, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return 0, ErrClosed + } + + length, err := o.document.backend.Length(ctx, o.handle) + if err != nil { + return 0, fmt.Errorf("cannot get Automerge object length: %w", err) + } + + return length, nil +} + +// Keys returns visible map property names in lexical order. +func (o *Object) Keys(ctx context.Context) ([]string, error) { + o.document.mu.Lock() + defer o.document.mu.Unlock() + + if o.document.closed { + return nil, ErrClosed + } + + keys, err := o.document.backend.Keys(ctx, o.handle) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge map keys: %w", err) + } + + return keys, nil +} + +// Text returns a collaborative text wrapper for a text object. +func (o *Object) Text() (*Text, error) { + if o.Type != ObjectTypeText { + return nil, fmt.Errorf("automerge object is %q, not text", o.Type) + } + + return &Text{document: o.document, handle: o.handle}, nil +} + +func validObjectType(value ObjectType) bool { + switch value { + case ObjectTypeMap, ObjectTypeList, ObjectTypeText, ObjectTypeTable: + return true + default: + return false + } +} + +func backendHashes(heads []Hash) [][32]byte { + result := make([][32]byte, len(heads)) + for i, head := range heads { + result[i] = [32]byte(head) + } + + return result +} diff --git a/pkg/automerge/orphan_save_load_parity_test.go b/pkg/automerge/orphan_save_load_parity_test.go new file mode 100644 index 0000000000..9584d84eec --- /dev/null +++ b/pkg/automerge/orphan_save_load_parity_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file reproduces the orphan save/load tests +// (rust/automerge/tests/test_save_load_orphans.rs): a document that holds an +// orphan change (a change whose dependency has not been applied) must retain +// that orphan across a save by default, so applying the missing dependency after +// a reload resolves it, and must be able to discard it on request. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// orphanScenario builds a document that has applied one change ("value") and +// holds an orphan change ("value3") whose dependency ("value2") is missing. It +// returns that document and the missing dependency change so a caller can apply +// it after a save/load round trip. +func orphanScenario( + t *testing.T, + ctx context.Context, + engine rustParityEngine, +) (*automerge.Document, []byte) { + t.Helper() + + doc1, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + + putRoot(t, ctx, doc1, "key", "value", "value", commitTime) + + doc2, err := doc1.Fork(ctx, actor(0x02)) + require.NoError(t, err) + closeDocument(t, doc2) + + _, err = doc2.SaveIncremental(ctx) + require.NoError(t, err) + + putRoot(t, ctx, doc2, "key", "value2", "value2", commitTime) + + missing, err := doc2.SaveIncremental(ctx) + require.NoError(t, err) + + putRoot(t, ctx, doc2, "key", "value3", "value3", commitTime) + + dependent, err := doc2.SaveIncremental(ctx) + require.NoError(t, err) + + // Applying the second remote change orphans it because doc1 lacks the first. + _, err = doc1.LoadIncremental(ctx, dependent) + require.NoError(t, err) + + return doc1, missing +} + +func rootKey(t *testing.T, ctx context.Context, document *automerge.Document) string { + t.Helper() + + value, err := document.Root().Scalar(ctx, "key") + require.NoError(t, err) + + return value.String +} + +// TestRustOrphans_SaveOrphanedChanges reproduces save_orphaned_changes: the +// orphan survives a default save, so applying the missing dependency after a +// reload yields value3. +func TestRustOrphans_SaveOrphanedChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, missing := orphanScenario(t, ctx, engine) + closeDocument(t, doc) + + saved, err := doc.Save(ctx) + require.NoError(t, err) + + loaded, err := engine.load(ctx, saved, actor(0x03)) + require.NoError(t, err) + closeDocument(t, loaded) + + _, err = loaded.LoadIncremental(ctx, missing) + require.NoError(t, err) + + require.Equal(t, "value3", rootKey(t, ctx, loaded)) + }) + } +} + +// TestRustOrphans_DiscardOrphans reproduces discard_orphans: saving with +// retain_orphans disabled drops the orphan, so after a reload only the value +// from the applicable change (value2) is seen. +func TestRustOrphans_DiscardOrphans(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, missing := orphanScenario(t, ctx, engine) + closeDocument(t, doc) + + saved, err := doc.SaveWithOptions(ctx, false) + require.NoError(t, err) + + loaded, err := engine.load(ctx, saved, actor(0x03)) + require.NoError(t, err) + closeDocument(t, loaded) + + _, err = loaded.LoadIncremental(ctx, missing) + require.NoError(t, err) + + require.Equal(t, "value2", rootKey(t, ctx, loaded)) + }) + } +} diff --git a/pkg/automerge/parity_manifest_test.go b/pkg/automerge/parity_manifest_test.go new file mode 100644 index 0000000000..771e0420df --- /dev/null +++ b/pkg/automerge/parity_manifest_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type ( + parityManifest struct { + SchemaVersion int `json:"schemaVersion"` + Sources parityManifestSources `json:"sources"` + Tests []parityManifestTest `json:"tests"` + } + + parityManifestSources struct { + Rust parityManifestSource `json:"rust"` + JavaScript parityManifestSource `json:"javascript"` + } + + parityManifestSource struct { + Version string `json:"version"` + GitCommit string `json:"gitCommit"` + CrateChecksum string `json:"crateChecksum"` + PackageIntegrity string `json:"npmIntegrity"` + } + + parityManifestTest struct { + ID string `json:"id"` + Source string `json:"source"` + File string `json:"file"` + Line int `json:"line"` + Name string `json:"name"` + Classification string `json:"classification"` + Requirement string `json:"requirement"` + LocalTests []string `json:"localTests"` + Rationale string `json:"rationale"` + } +) + +func TestUpstreamParityManifest(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("testdata/upstream-parity.json") + require.NoError(t, err) + + var manifest parityManifest + require.NoError(t, json.Unmarshal(data, &manifest)) + assert.Equal(t, 1, manifest.SchemaVersion) + assert.Equal(t, "0.10.0", manifest.Sources.Rust.Version) + assert.Equal( + t, + "a4f584c86358dd07f83f36708573e1c8d1bd8161", + manifest.Sources.Rust.GitCommit, + ) + assert.Equal( + t, + "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633", + manifest.Sources.Rust.CrateChecksum, + ) + assert.Equal(t, "3.4.0", manifest.Sources.JavaScript.Version) + assert.Equal( + t, + "f8b0911dc9d86265dd62934b7dc782571e3a7fcb", + manifest.Sources.JavaScript.GitCommit, + ) + assert.Equal( + t, + "sha512-THmghtTNGGt2xsI0pM3o1i3PM8oZKcYFgOj25FOzW7l6e94SQOivNtCwy6xc0I8hVJsQSSotoBNs+yk/9hM2dg==", + manifest.Sources.JavaScript.PackageIntegrity, + ) + + seen := make(map[string]struct{}, len(manifest.Tests)) + pending := make([]string, 0) + interopPending := make([]string, 0) + sourceCounts := make(map[string]int) + + for _, test := range manifest.Tests { + require.NotEmpty(t, test.ID) + require.NotEmpty(t, test.Source) + require.NotEmpty(t, test.File) + require.Positive(t, test.Line) + require.NotEmpty(t, test.Name) + + _, duplicate := seen[test.ID] + assert.False(t, duplicate, "duplicate parity test %q", test.ID) + seen[test.ID] = struct{}{} + sourceCounts[test.Source]++ + + switch test.Classification { + case "covered": + assert.NotEmpty(t, test.LocalTests, "covered test %q has no mapping", test.ID) + case "language-specific": + assert.NotEmpty( + t, + test.Rationale, + "language-specific test %q has no rationale", + test.ID, + ) + case "pending": + pending = append(pending, test.ID) + default: + assert.Fail( + t, + "invalid parity classification", + "test %q has classification %q", + test.ID, + test.Classification, + ) + } + + switch test.Requirement { + case "interop-required", "api-convenience": + if test.Classification == "language-specific" { + assert.Fail( + t, + "invalid parity requirement", + "language-specific test %q is marked %q", + test.ID, + test.Requirement, + ) + } + case "language-specific": + assert.Equal(t, "language-specific", test.Classification) + default: + assert.Fail( + t, + "invalid parity requirement", + "test %q has requirement %q", + test.ID, + test.Requirement, + ) + } + + if test.Classification == "pending" && + test.Requirement == "interop-required" { + interopPending = append(interopPending, test.ID) + } + } + + assert.Equal(t, 361, sourceCounts["rust"]) + assert.Equal(t, 16, sourceCounts["rust-doc"]) + assert.Equal(t, 319, sourceCounts["javascript"]) + assert.Equal(t, 16, sourceCounts["javascript-packaging"]) + + if os.Getenv("AUTOMERGE_REQUIRE_FULL_PARITY") == "1" { + const previewLimit = 10 + + preview := pending + if len(preview) > previewLimit { + preview = preview[:previewLimit] + } + + if len(pending) > 0 { + t.Fatalf( + "%d upstream tests remain unmapped; first %d: %v", + len(pending), + len(preview), + preview, + ) + } + } + + if os.Getenv("AUTOMERGE_REQUIRE_FULL_INTEROP") == "1" && + len(interopPending) > 0 { + const previewLimit = 10 + + preview := interopPending + if len(preview) > previewLimit { + preview = preview[:previewLimit] + } + + t.Fatalf( + "%d interoperability tests remain unmapped; first %d: %v", + len(interopPending), + len(preview), + preview, + ) + } +} diff --git a/pkg/automerge/patch.go b/pkg/automerge/patch.go new file mode 100644 index 0000000000..585af6ca1e --- /dev/null +++ b/pkg/automerge/patch.go @@ -0,0 +1,274 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge + +import ( + "context" + "encoding/json" + "fmt" +) + +type ( + // PatchActionType identifies the kind of change a patch represents. + PatchActionType string + + // PatchValue is the value carried by a put or insert patch. Object holds a + // composite object type when the value is an object; otherwise Scalar holds + // the scalar value. + PatchValue struct { + Scalar *Scalar + Object ObjectType + ObjectID string + } + + // InsertedValue is one value produced by an insert patch. + InsertedValue struct { + Value PatchValue + Conflict bool + } + + // Patch describes a single change to a document's materialized value. + Patch struct { + Object string + Action PatchActionType + Key string + Index uint64 + Length uint64 + Value PatchValue + Values []InsertedValue + Text string + Delta int64 + Conflict bool + Marks []Mark + } +) + +const ( + PatchPutMap PatchActionType = "put_map" + PatchPutSeq PatchActionType = "put_seq" + PatchInsert PatchActionType = "insert" + PatchSpliceText PatchActionType = "splice_text" + PatchIncrement PatchActionType = "increment" + PatchConflict PatchActionType = "conflict" + PatchDeleteMap PatchActionType = "delete_map" + PatchDeleteSeq PatchActionType = "delete_seq" + PatchMark PatchActionType = "mark" +) + +type ( + encodedPatch struct { + Obj string `json:"obj"` + Action encodedPatchAction `json:"action"` + } + + encodedPatchAction struct { + Type string `json:"type"` + Key string `json:"key"` + Index uint64 `json:"index"` + Length uint64 `json:"length"` + Value *encodedPatchValue `json:"value"` + Values []encodedInsertsValue `json:"values"` + Text string `json:"text,omitempty"` + Delta int64 `json:"delta"` + Conflict bool `json:"conflict"` + Prop *encodedPatchProp `json:"prop"` + Marks []encodedMark `json:"marks"` + } + + encodedInsertsValue struct { + Value encodedPatchValue `json:"value"` + Conflict bool `json:"conflict"` + } + + encodedPatchValue struct { + Scalar json.RawMessage `json:"scalar"` + Object string `json:"object"` + ID string `json:"id"` + } + + encodedPatchProp struct { + Key *string `json:"key"` + Index *uint64 `json:"index"` + } +) + +// CurrentState returns the patches that materialize the document's current +// value from an empty document. +func (d *Document) CurrentState(ctx context.Context) ([]Patch, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.CurrentState(ctx) + if err != nil { + return nil, fmt.Errorf("cannot read Automerge current state: %w", err) + } + + return decodePatches(data) +} + +// UpdateDiffCursor records the current heads as the incremental diff cursor so a +// following DiffIncremental reports only the changes committed since this call. +func (d *Document) UpdateDiffCursor(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + if err := d.backend.UpdateDiffCursor(ctx); err != nil { + return fmt.Errorf("cannot update Automerge diff cursor: %w", err) + } + + return nil +} + +// DiffIncremental returns the patches for the changes committed since the diff +// cursor and advances the cursor to the current heads. It mirrors the Rust +// AutoCommit::diff_incremental helper, reporting operations from the recorded +// patch log so an in-place text replacement is a put rather than a splice. +func (d *Document) DiffIncremental(ctx context.Context) ([]Patch, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.DiffIncremental(ctx) + if err != nil { + return nil, fmt.Errorf("cannot compute Automerge incremental diff: %w", err) + } + + return decodePatches(data) +} + +// Diff returns the patches that transform the document state at the before +// heads into the state at the after heads. +func (d *Document) Diff( + ctx context.Context, + before []Hash, + after []Hash, +) ([]Patch, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + data, err := d.backend.Diff(ctx, backendHashes(before), backendHashes(after)) + if err != nil { + return nil, fmt.Errorf("cannot diff Automerge document: %w", err) + } + + return decodePatches(data) +} + +func decodePatches(data []byte) ([]Patch, error) { + var encoded []encodedPatch + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, fmt.Errorf("cannot decode Automerge patches: %w", err) + } + + patches := make([]Patch, len(encoded)) + for i, source := range encoded { + patch := Patch{ + Object: source.Obj, + Action: PatchActionType(source.Action.Type), + Key: source.Action.Key, + Index: source.Action.Index, + Length: source.Action.Length, + Text: source.Action.Text, + Delta: source.Action.Delta, + Conflict: source.Action.Conflict, + } + + if source.Action.Value != nil { + value, err := decodePatchValue(*source.Action.Value) + if err != nil { + return nil, err + } + + patch.Value = value + } + + for _, inserted := range source.Action.Values { + value, err := decodePatchValue(inserted.Value) + if err != nil { + return nil, err + } + + patch.Values = append(patch.Values, InsertedValue{ + Value: value, + Conflict: inserted.Conflict, + }) + } + + if source.Action.Prop != nil { + if source.Action.Prop.Key != nil { + patch.Key = *source.Action.Prop.Key + } + + if source.Action.Prop.Index != nil { + patch.Index = *source.Action.Prop.Index + } + } + + for _, mark := range source.Action.Marks { + value, err := decodeScalarWire(mark.Value) + if err != nil { + return nil, err + } + + patch.Marks = append(patch.Marks, Mark{ + Start: mark.Start, + End: mark.End, + Name: mark.Name, + Value: value, + }) + } + + patches[i] = patch + } + + return patches, nil +} + +func decodePatchValue(source encodedPatchValue) (PatchValue, error) { + if source.Object != "" { + return PatchValue{ + Object: ObjectType(source.Object), + ObjectID: source.ID, + }, nil + } + + scalar, err := decodeScalarWire(source.Scalar) + if err != nil { + return PatchValue{}, fmt.Errorf("cannot decode patch scalar: %w", err) + } + + return PatchValue{Scalar: &scalar}, nil +} diff --git a/pkg/automerge/prosemirror/fuzz_test.go b/pkg/automerge/prosemirror/fuzz_test.go new file mode 100644 index 0000000000..4f905def0f --- /dev/null +++ b/pkg/automerge/prosemirror/fuzz_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package prosemirror_test + +import ( + "encoding/json" + "testing" + + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" +) + +func FuzzRender(f *testing.F) { + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"paragraph","parents":[]}}, + {"Type":"text","Text":"Hello"} + ]`)) + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"horizontal-rule","parents":[]}}, + {"Type":"text","Text":"Preserved"} + ]`)) + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"table","parents":[]}}, + {"Type":"block","Block":{"type":"table-row","parents":["table"]}}, + {"Type":"block","Block":{"type":"table-cell","parents":["table","table-row"]}}, + {"Type":"text","Text":"Cell"} + ]`)) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1024*1024 { + t.Skip() + } + + var spans []automerge.Span + if err := json.Unmarshal(data, &spans); err != nil { + return + } + + if len(spans) > 100_000 { + t.Skip() + } + + _, _ = automergeprosemirror.Render(spans) + }) +} diff --git a/pkg/automerge/prosemirror/render.go b/pkg/automerge/prosemirror/render.go index 9c36142c8c..1e4caeced7 100644 --- a/pkg/automerge/prosemirror/render.go +++ b/pkg/automerge/prosemirror/render.go @@ -132,8 +132,19 @@ func collectBlocks(spans []automerge.Span) ([]block, error) { }, ) case automerge.SpanTypeText: - if len(blocks) == 0 { - blocks = append(blocks, block{Type: blockTypeParagraph}) + if len(blocks) == 0 || blocks[len(blocks)-1].Type == blockTypeHorizontalRule { + var parents []string + if len(blocks) > 0 { + parents = slices.Clone(blocks[len(blocks)-1].Parents) + } + + blocks = append( + blocks, + block{ + Type: blockTypeParagraph, + Parents: parents, + }, + ) } marks, err := renderMarks(span.Marks) diff --git a/pkg/automerge/prosemirror/render_test.go b/pkg/automerge/prosemirror/render_test.go index ffe3064e9f..6b83b604b9 100644 --- a/pkg/automerge/prosemirror/render_test.go +++ b/pkg/automerge/prosemirror/render_test.go @@ -227,6 +227,42 @@ func TestRender_HardBreakAndHorizontalRule(t *testing.T) { require.NoError(t, err) } +func TestRender_HorizontalRuleFollowedByText(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "horizontal-rule", + "parents": []any{}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Preserved"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + {"type": "horizontalRule"}, + { + "type": "paragraph", + "content": [{"type": "text", "text": "Preserved"}] + } + ] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + require.NoError(t, err) +} + func TestRender_TableStructure(t *testing.T) { t.Parallel() diff --git a/pkg/automerge/rich_text.go b/pkg/automerge/rich_text.go index 22d0cb4dc3..a141e1a64c 100644 --- a/pkg/automerge/rich_text.go +++ b/pkg/automerge/rich_text.go @@ -29,6 +29,9 @@ import ( type ( SpanType string + // MarkExpand controls whether inserted text inherits a mark at its edges. + MarkExpand string + Span struct { Type SpanType Text string @@ -46,8 +49,295 @@ type ( const ( SpanTypeBlock SpanType = "block" SpanTypeText SpanType = "text" + + MarkExpandBefore MarkExpand = "before" + MarkExpandAfter MarkExpand = "after" + MarkExpandBoth MarkExpand = "both" + MarkExpandNone MarkExpand = "none" ) +// Mark applies a typed annotation to a UTF-16 text range. +func (t *Text) Mark( + ctx context.Context, + start uint32, + end uint32, + name string, + value Scalar, + expand MarkExpand, +) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + if !validMarkExpand(expand) { + return fmt.Errorf("unknown Automerge mark expansion %q", expand) + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode Automerge mark value: %w", err) + } + + if err := t.document.backend.MarkText( + ctx, + t.handle, + start, + end, + name, + encoded, + string(expand), + ); err != nil { + return fmt.Errorf("cannot mark Automerge text: %w", err) + } + + return nil +} + +// SpanInput is one span supplied to UpdateSpans. When Block is non-nil the span +// is a block marker carrying those attributes; otherwise it is a text span whose +// Marks map names the annotations active over the span. +type SpanInput struct { + Text string + Marks map[string]Scalar + Block map[string]any +} + +// UpdateSpansConfig controls the mark expansion applied by UpdateSpans. +type UpdateSpansConfig struct { + DefaultExpand MarkExpand + PerMarkExpands map[string]MarkExpand +} + +// UpdateSpans reconciles the text so its spans equal the supplied spans, +// computing a minimal text diff and then setting the marks to exactly those +// named on the spans. It mirrors the Rust updateSpans helper. +func (t *Text) UpdateSpans( + ctx context.Context, + spans []SpanInput, + config UpdateSpansConfig, +) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + encodedSpans := make([]map[string]any, 0, len(spans)) + + for _, span := range spans { + if span.Block != nil { + encodedSpans = append(encodedSpans, map[string]any{ + "type": "block", + "block": span.Block, + }) + + continue + } + + marks := make(map[string]json.RawMessage, len(span.Marks)) + + for name, value := range span.Marks { + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode span mark %q: %w", name, err) + } + + marks[name] = json.RawMessage(encoded) + } + + encodedSpans = append(encodedSpans, map[string]any{ + "type": "text", + "text": span.Text, + "marks": marks, + }) + } + + spansPayload, err := json.Marshal(encodedSpans) + if err != nil { + return fmt.Errorf("cannot encode Automerge spans: %w", err) + } + + perMark := make(map[string]string, len(config.PerMarkExpands)) + for name, expand := range config.PerMarkExpands { + if expand == "" { + continue + } + + perMark[name] = string(expand) + } + + configuration := map[string]any{"perMarkExpands": perMark} + if config.DefaultExpand != "" { + configuration["defaultExpand"] = string(config.DefaultExpand) + } + + configPayload, err := json.Marshal(configuration) + if err != nil { + return fmt.Errorf("cannot encode Automerge spans config: %w", err) + } + + if err := t.document.backend.UpdateSpans(ctx, t.handle, spansPayload, configPayload); err != nil { + return fmt.Errorf("cannot update Automerge spans: %w", err) + } + + return nil +} + +// Unmark removes a named annotation from a UTF-16 text range. +func (t *Text) Unmark( + ctx context.Context, + start uint32, + end uint32, + name string, + expand MarkExpand, +) error { + return t.Mark( + ctx, + start, + end, + name, + Scalar{Type: ScalarTypeNull}, + expand, + ) +} + +// SplitBlock inserts a block marker at a UTF-16 text position. +func (t *Text) SplitBlock(ctx context.Context, index uint32) (*Object, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + handle, err := t.document.backend.SplitBlock(ctx, t.handle, index) + if err != nil { + return nil, fmt.Errorf("cannot split Automerge block: %w", err) + } + + return &Object{ + document: t.document, + handle: handle, + Type: ObjectTypeMap, + }, nil +} + +// JoinBlock deletes the block marker at a UTF-16 text position. +func (t *Text) JoinBlock(ctx context.Context, index uint32) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + if err := t.document.backend.JoinBlock(ctx, t.handle, index); err != nil { + return fmt.Errorf("cannot join Automerge block: %w", err) + } + + return nil +} + +// ReplaceBlock replaces a block marker and returns its new map object. +func (t *Text) ReplaceBlock(ctx context.Context, index uint32) (*Object, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + handle, err := t.document.backend.ReplaceBlock(ctx, t.handle, index) + if err != nil { + return nil, fmt.Errorf("cannot replace Automerge block: %w", err) + } + + return &Object{ + document: t.document, + handle: handle, + Type: ObjectTypeMap, + }, nil +} + +type ( + // Mark is one active annotation over a UTF-16 range of a text object. + Mark struct { + Start uint32 + End uint32 + Name string + Value Scalar + } + + encodedMark struct { + Start uint32 `json:"start"` + End uint32 `json:"end"` + Name string `json:"name"` + Value json.RawMessage `json:"value"` + } +) + +// Marks returns the active marks over the text object as UTF-16 ranges. +func (t *Text) Marks(ctx context.Context) ([]Mark, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + data, err := t.document.backend.Marks(ctx, t.handle) + if err != nil { + return nil, fmt.Errorf("cannot read Automerge marks: %w", err) + } + + return decodeMarks(data) +} + +// MarksAt returns the active marks over the text object at a historical frontier. +func (t *Text) MarksAt(ctx context.Context, heads []Hash) ([]Mark, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + data, err := t.document.backend.MarksAt(ctx, t.handle, backendHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot read historical Automerge marks: %w", err) + } + + return decodeMarks(data) +} + +func decodeMarks(data []byte) ([]Mark, error) { + var encoded []encodedMark + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, fmt.Errorf("cannot decode Automerge marks: %w", err) + } + + marks := make([]Mark, len(encoded)) + for i, source := range encoded { + value, err := decodeScalarWire(source.Value) + if err != nil { + return nil, fmt.Errorf("cannot decode Automerge mark %d value: %w", i, err) + } + + marks[i] = Mark{ + Start: source.Start, + End: source.End, + Name: source.Name, + Value: value, + } + } + + return marks, nil +} + func (t *Text) Spans(ctx context.Context) ([]Span, error) { t.document.mu.Lock() defer t.document.mu.Unlock() @@ -61,6 +351,27 @@ func (t *Text) Spans(ctx context.Context) ([]Span, error) { return nil, fmt.Errorf("cannot read Automerge rich-text spans: %w", err) } + return decodeSpans(data) +} + +// SpansAt returns the rich-text spans as they existed at a historical frontier. +func (t *Text) SpansAt(ctx context.Context, heads []Hash) ([]Span, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + data, err := t.document.backend.TextSpansAt(ctx, t.handle, backendHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot read historical Automerge rich-text spans: %w", err) + } + + return decodeSpans(data) +} + +func decodeSpans(data []byte) ([]Span, error) { var encoded []encodedSpan if err := json.Unmarshal(data, &encoded); err != nil { return nil, fmt.Errorf("cannot decode Automerge rich-text spans: %w", err) @@ -87,3 +398,15 @@ func (t *Text) Spans(ctx context.Context) ([]Span, error) { return spans, nil } + +func validMarkExpand(value MarkExpand) bool { + switch value { + case MarkExpandBefore, + MarkExpandAfter, + MarkExpandBoth, + MarkExpandNone: + return true + default: + return false + } +} diff --git a/pkg/automerge/rich_text_parity_test.go b/pkg/automerge/rich_text_parity_test.go new file mode 100644 index 0000000000..9c78577bf6 --- /dev/null +++ b/pkg/automerge/rich_text_parity_test.go @@ -0,0 +1,683 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust rich-text tests from automerge +// 0.10 (rust/automerge/tests/block_tests.rs and text.rs) that assert on the +// materialized span stream after mark, splice, and block operations. Each +// scenario runs identically on the native Go engine and the Rust/WASM reference +// engine and asserts their span output agrees. + +package automerge_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func markTrue() automerge.Scalar { + return automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true} +} + +func markString(value string) automerge.Scalar { + return automerge.Scalar{Type: automerge.ScalarTypeString, String: value} +} + +// richTextSpans builds a rich-text document with the given closure on each +// engine and returns the resulting span stream keyed by engine name. +func richTextSpans( + t *testing.T, + build func(t *testing.T, ctx context.Context, text *automerge.Text), +) map[string][]automerge.Span { + t.Helper() + + ctx := context.Background() + spans := make(map[string][]automerge.Span) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + build(t, ctx, text) + + _, err = document.CommitNow(ctx, "rich text") + require.NoError(t, err) + + result, err := text.Spans(ctx) + require.NoError(t, err) + + spans[engine.name] = result + } + + return spans +} + +// TestRustRichText_MarksInSpansCrossBlockMarkers reproduces +// marks_in_spans_cross_block_markers. +func TestRustRichText_MarksInSpansCrossBlockMarkers(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "lix")) + require.NoError(t, text.Mark(ctx, 0, 3, "bold", markTrue(), automerge.MarkExpandAfter)) + _, err := text.SplitBlock(ctx, 1) + require.NoError(t, err) + }) + + assert.Equal(t, spans["reference"], spans["native"]) +} + +// TestRustRichText_MarkBehaviorOnDeleteInsert reproduces +// test_mark_behavior_on_delete_insert. +func TestRustRichText_MarkBehaviorOnDeleteInsert(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "hello")) + require.NoError(t, text.Mark(ctx, 0, 5, "bold", markTrue(), automerge.MarkExpandBoth)) + require.NoError(t, text.Splice(ctx, 0, 5, "")) + require.NoError(t, text.Splice(ctx, 0, 0, "hi")) + }) + + assert.Equal(t, spans["reference"], spans["native"]) + require.Len(t, spans["native"], 1) + assert.Equal(t, "hi", spans["native"][0].Text) + assert.Empty(t, spans["native"][0].Marks) +} + +// TestRustRichText_SpansConsolidateEmptyDueToDeletedMarks reproduces +// spans_consolidates_marks_which_are_empty_due_to_deleted_marks. +func TestRustRichText_SpansConsolidateEmptyDueToDeletedMarks(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "hello middle world")) + require.NoError(t, text.Mark(ctx, 0, 9, "bold", markTrue(), automerge.MarkExpandNone)) + require.NoError(t, text.Mark(ctx, 9, 18, "italic", markTrue(), automerge.MarkExpandNone)) + require.NoError(t, text.Unmark(ctx, 6, 9, "bold", automerge.MarkExpandNone)) + require.NoError(t, text.Unmark(ctx, 9, 12, "italic", automerge.MarkExpandNone)) + }) + + assert.Equal(t, spans["reference"], spans["native"]) +} + +// TestRustRichText_SpansConsolidateDeletedThenEmptyMarks reproduces +// spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks. +func TestRustRichText_SpansConsolidateDeletedThenEmptyMarks(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark(ctx, 0, 6, "bold", markTrue(), automerge.MarkExpandNone)) + require.NoError(t, text.Unmark(ctx, 0, 6, "bold", automerge.MarkExpandNone)) + }) + + assert.Equal(t, spans["reference"], spans["native"]) + require.Len(t, spans["native"], 1) + assert.Equal(t, "hello world", spans["native"][0].Text) + assert.Empty(t, spans["native"][0].Marks) +} + +// TestRustRichText_SpansConsolidateEmptyThenDeletedMarks reproduces +// spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks. +func TestRustRichText_SpansConsolidateEmptyThenDeletedMarks(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark(ctx, 6, 11, "bold", markTrue(), automerge.MarkExpandNone)) + require.NoError(t, text.Unmark(ctx, 6, 11, "bold", automerge.MarkExpandNone)) + }) + + assert.Equal(t, spans["reference"], spans["native"]) + require.Len(t, spans["native"], 1) + assert.Equal(t, "hello world", spans["native"][0].Text) +} + +// TestRustRichText_SpliceWithMark reproduces test_splice_with_mark. +func TestRustRichText_SpliceWithMark(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "abc")) + require.NoError(t, text.Mark( + ctx, + 1, + 2, + "some_nonexpanding_mark_type", + markString("marked"), + automerge.MarkExpandNone, + )) + require.NoError(t, text.Mark( + ctx, + 1, + 2, + "some_expanding_mark_type", + markString("marked"), + automerge.MarkExpandBoth, + )) + require.NoError(t, text.Splice(ctx, 1, 1, "d")) + }) + + assert.Equal(t, spans["reference"], spans["native"]) +} + +func textMarks( + t *testing.T, + build func(t *testing.T, ctx context.Context, text *automerge.Text), +) map[string][]automerge.Mark { + t.Helper() + + ctx := context.Background() + marks := make(map[string][]automerge.Mark) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + build(t, ctx, text) + + _, err = document.CommitNow(ctx, "marks") + require.NoError(t, err) + + result, err := text.Marks(ctx) + require.NoError(t, err) + + marks[engine.name] = result + } + + return marks +} + +// TestRustRichText_RemovedMarksNotInGetMarks reproduces +// removed_marks_should_not_appear_in_get_marks. +func TestRustRichText_RemovedMarksNotInGetMarks(t *testing.T) { + t.Parallel() + + marks := textMarks(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "abcdefg")) + require.NoError(t, text.Mark( + ctx, + 0, + 1, + "name1", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + automerge.MarkExpandNone, + )) + require.NoError(t, text.Unmark(ctx, 0, 1, "name1", automerge.MarkExpandNone)) + }) + + assert.Equal(t, marks["reference"], marks["native"]) + assert.Empty(t, marks["native"]) +} + +// TestRustRichText_InsertingTextNearDeletedMarks reproduces +// inserting_text_near_deleted_marks. +func TestRustRichText_InsertingTextNearDeletedMarks(t *testing.T) { + t.Parallel() + + marks := textMarks(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark(ctx, 2, 8, "bold", markTrue(), automerge.MarkExpandAfter)) + require.NoError(t, text.Mark(ctx, 3, 6, "link", markTrue(), automerge.MarkExpandNone)) + require.NoError(t, text.Splice(ctx, 1, 10, "")) + require.NoError(t, text.Splice(ctx, 0, 0, "a")) + require.NoError(t, text.Splice(ctx, 2, 0, "a")) + }) + + assert.Equal(t, marks["reference"], marks["native"]) +} + +// TestRustRichText_GetMarksAtHeads reproduces get_marks_at_heads: marks active +// at a specific index resolved at a historical frontier. +func TestRustRichText_GetMarksAtHeads(t *testing.T) { + t.Parallel() + + ctx := context.Background() + active := make(map[string]map[string]int64) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark(ctx, 0, 10, "bold", markTrue(), automerge.MarkExpandAfter)) + _, err = document.Commit(ctx, "bold", commitTime) + require.NoError(t, err) + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + require.NoError(t, text.Unmark(ctx, 0, 10, "bold", automerge.MarkExpandNone)) + _, err = document.Commit(ctx, "unbold", commitTime.Add(time.Second)) + require.NoError(t, err) + + marks, err := text.MarksAt(ctx, heads) + require.NoError(t, err) + + atIndex := make(map[string]int64) + + for _, mark := range marks { + if uint32(1) >= mark.Start && uint32(1) < mark.End { + value := int64(0) + if mark.Value.Bool { + value = 1 + } + + atIndex[mark.Name] = value + } + } + + active[engine.name] = atIndex + } + + assert.Equal(t, active["reference"], active["native"]) + assert.Equal(t, map[string]int64{"bold": 1}, active["native"]) +} + +// TestRustText_ExpandMarksAreReportedInPatches reproduces +// expand_marks_are_reported_in_patches: a both-expanding mark includes text +// inserted at either boundary and both incremental splice patches carry it. +func TestRustText_ExpandMarksAreReportedInPatches(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + marks := make(map[string][]automerge.Mark) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "aaabbbccc")) + require.NoError(t, text.Mark( + ctx, + 3, + 6, + "strong", + markTrue(), + automerge.MarkExpandBoth, + )) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + require.NoError(t, document.UpdateDiffCursor(ctx)) + + var patches []automerge.Patch + + require.NoError(t, text.Splice(ctx, 6, 0, "<")) + _, err = document.Commit(ctx, "end", commitTime.Add(time.Second)) + require.NoError(t, err) + endPatches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + patches = append(patches, endPatches...) + + require.NoError(t, text.Splice(ctx, 3, 0, ">")) + _, err = document.Commit(ctx, "start", commitTime.Add(2*time.Second)) + require.NoError(t, err) + startPatches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + patches = append(patches, startPatches...) + + result[engine.name] = patches + marks[engine.name], err = text.Marks(ctx) + require.NoError(t, err) + } + + reference := result["reference"] + require.Len(t, reference, 2) + + for _, patch := range reference { + assert.Equal(t, automerge.PatchSpliceText, patch.Action) + require.Len(t, patch.Marks, 1) + assert.Equal(t, "strong", patch.Marks[0].Name) + } + + assert.Equal(t, uint64(6), reference[0].Index) + assert.Equal(t, "<", reference[0].Text) + assert.Equal(t, uint64(3), reference[1].Index) + assert.Equal(t, ">", reference[1].Text) + assert.Equal(t, result["reference"], result["native"]) + assert.Equal(t, marks["reference"], marks["native"]) + assert.Equal(t, uint32(3), marks["native"][0].Start) + assert.Equal(t, uint32(8), marks["native"][0].End) +} + +// TestRustText_RemotePatchesForExpandAfter reproduces +// test_remote_patches_for_marks_with_expand_after: applying a remote insertion +// at an after-expanding boundary produces the same marked splice patch as the +// local document. +func TestRustText_RemotePatchesForExpandAfter(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + documentA, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, documentA) + + textA, err := documentA.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, textA.Splice(ctx, 0, 0, "fox")) + require.NoError(t, textA.Mark( + ctx, + 0, + 3, + "strong", + markTrue(), + automerge.MarkExpandAfter, + )) + _, err = documentA.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + documentB, err := documentA.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, documentB) + + beforeA, err := documentA.Heads(ctx) + require.NoError(t, err) + require.NoError(t, textA.Splice(ctx, 3, 0, "a")) + afterA, err := documentA.Commit(ctx, "append", commitTime.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, documentB.UpdateDiffCursor(ctx)) + beforeB, err := documentB.Heads(ctx) + require.NoError(t, err) + _, err = documentB.Merge(ctx, documentA) + require.NoError(t, err) + afterB, err := documentB.Heads(ctx) + require.NoError(t, err) + + local, err := documentA.Diff(ctx, beforeA, []automerge.Hash{afterA}) + require.NoError(t, err) + remote, err := documentB.Diff(ctx, beforeB, afterB) + require.NoError(t, err) + + assert.Equal(t, local, remote) + result[engine.name] = local + } + + reference := result["reference"] + require.Len(t, reference, 1) + assert.Equal(t, automerge.PatchSpliceText, reference[0].Action) + assert.Equal(t, uint64(3), reference[0].Index) + assert.Equal(t, "a", reference[0].Text) + require.Len(t, reference[0].Marks, 1) + assert.Equal(t, "strong", reference[0].Marks[0].Name) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustMarks_ExpansionAndUnmark reproduces tests/test.rs marks: a +// both-expanding mark grows at its end, an unmark removes only the original +// prefix, and text inserted before the unmarked range remains unmarked. +func TestRustMarks_ExpansionAndUnmark(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Mark) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + require.NoError(t, text.Mark( + ctx, + 0, + 5, + "bold", + markTrue(), + automerge.MarkExpandBoth, + )) + require.NoError(t, text.Splice(ctx, 5, 0, " cool")) + require.NoError(t, text.Unmark( + ctx, + 0, + 5, + "bold", + automerge.MarkExpandBefore, + )) + require.NoError(t, text.Splice(ctx, 0, 0, "why ")) + _, err = document.Commit(ctx, "marks", commitTime) + require.NoError(t, err) + + result[engine.name], err = text.Marks(ctx) + require.NoError(t, err) + } + + assert.Equal(t, result["reference"], result["native"]) + require.Len(t, result["native"], 1) + assert.Equal(t, uint32(9), result["native"][0].Start) + assert.Equal(t, uint32(14), result["native"][0].End) + assert.Equal(t, "bold", result["native"][0].Name) + assert.Equal(t, markTrue(), result["native"][0].Value) +} + +// TestRustText_CrossPageMarksNotDoubleCounted reproduces +// marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches. +// A mark crossing the reference engine's operation-tree page boundary must not +// leak onto text appended much later after unrelated block insertions. +func TestRustText_CrossPageMarksNotDoubleCounted(t *testing.T) { + t.Parallel() + + const pageSize = 16 + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + textObject, err := document.Root().Object(ctx, "text") + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 0, 0, strings.Repeat("a", pageSize*2))) + require.NoError(t, text.Mark( + ctx, + pageSize-1, + pageSize+1, + "strong", + markTrue(), + automerge.MarkExpandNone, + )) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + for iteration := 0; iteration < 100; iteration++ { + length, err := textObject.Len(ctx) + require.NoError(t, err) + _, err = text.SplitBlock(ctx, uint32(length)) + require.NoError(t, err) + _, err = document.Commit(ctx, "block", commitTime.Add(time.Duration(iteration+1)*time.Second)) + require.NoError(t, err) + require.NoError(t, document.UpdateDiffCursor(ctx)) + + length, err = textObject.Len(ctx) + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, uint32(length), 0, "a")) + _, err = document.Commit(ctx, "append", commitTime.Add(time.Duration(iteration+101)*time.Second)) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + require.Len(t, patches, 1) + assert.Equal(t, automerge.PatchSpliceText, patches[0].Action) + assert.Empty(t, patches[0].Marks) + } + }) + } +} + +// TestRustRichText_EmptyMarksBeforeBlockMarker reproduces +// empty_marks_before_block_marker_dont_repeat_text. +func TestRustRichText_EmptyMarksBeforeBlockMarker(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + _, err := text.SplitBlock(ctx, 0) + require.NoError(t, err) + _, err = text.SplitBlock(ctx, 0) + require.NoError(t, err) + require.NoError(t, text.Mark(ctx, 1, 1, "strong", markTrue(), automerge.MarkExpandBoth)) + require.NoError(t, text.Splice(ctx, 2, 0, "a")) + }) + + assert.Equal(t, spans["reference"], spans["native"]) + require.Len(t, spans["native"], 3) + assert.Equal(t, automerge.SpanTypeBlock, spans["native"][0].Type) + assert.Equal(t, automerge.SpanTypeBlock, spans["native"][1].Type) + assert.Equal(t, automerge.SpanTypeText, spans["native"][2].Type) + assert.Equal(t, "a", spans["native"][2].Text) +} + +// TestRustRichText_ComplexBlockProperties reproduces +// text_complex_block_properties. +func TestRustRichText_ComplexBlockProperties(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + block, err := text.SplitBlock(ctx, 0) + require.NoError(t, err) + require.NoError(t, block.PutValue(ctx, "type", automerge.Value{ + Type: automerge.ValueTypeText, + Text: "ordered-list-item", + })) + require.NoError(t, block.PutValue(ctx, "parents", automerge.Value{ + Type: automerge.ValueTypeList, + List: []automerge.Value{{Type: automerge.ValueTypeText, Text: "div"}}, + })) + }) + + assert.Equal(t, spans["reference"], spans["native"]) +} + +// TestRustRichText_MarkCreatedAfterInsertion reproduces +// mark_created_after_insertion. +func TestRustRichText_MarkCreatedAfterInsertion(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "12345")) + require.NoError(t, text.Mark(ctx, 1, 2, "strong", markTrue(), automerge.MarkExpandBoth)) + require.NoError(t, text.Mark(ctx, 3, 4, "strong", markTrue(), automerge.MarkExpandBoth)) + }) + + assert.Equal(t, spans["reference"], spans["native"]) +} + +// TestRustRichText_SpansConsolidatedWithZeroLengthSpans reproduces +// spans_are_consolidated_in_the_presence_of_zero_length_spans. +func TestRustRichText_SpansConsolidatedWithZeroLengthSpans(t *testing.T) { + t.Parallel() + + spans := richTextSpans(t, func(t *testing.T, ctx context.Context, text *automerge.Text) { + require.NoError(t, text.Splice(ctx, 0, 0, "1234")) + require.NoError(t, text.Mark(ctx, 1, 1, "strong", markTrue(), automerge.MarkExpandBoth)) + require.NoError(t, text.Mark(ctx, 2, 2, "strong", markTrue(), automerge.MarkExpandBoth)) + }) + + assert.Equal(t, spans["reference"], spans["native"]) + require.Len(t, spans["native"], 1) + assert.Equal(t, "1234", spans["native"][0].Text) +} + +// TestRustRichText_DeletingInMiddleOfMultibyteChar reproduces +// deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character. +func TestRustRichText_DeletingInMiddleOfMultibyteChar(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + var observed []string + + require.NoError(t, text.Splice(ctx, 0, 0, "🐻🐻🐻🐻🐻🐻")) + value, err := text.String(ctx) + require.NoError(t, err) + + observed = append(observed, value) + + require.NoError(t, text.Splice(ctx, 2, 2, "A🐻A")) + value, err = text.String(ctx) + require.NoError(t, err) + + observed = append(observed, value) + + require.NoError(t, text.Splice(ctx, 4, 1, "X")) + value, err = text.String(ctx) + require.NoError(t, err) + + observed = append(observed, value) + + require.NoError(t, text.Splice(ctx, 4, 2, "Y")) + value, err = text.String(ctx) + require.NoError(t, err) + + observed = append(observed, value) + + _, err = document.CommitNow(ctx, "multibyte") + require.NoError(t, err) + + results[engine.name] = observed + } + + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, []string{ + "🐻🐻🐻🐻🐻🐻", + "🐻A🐻A🐻🐻🐻🐻", + "🐻A🐻X🐻🐻🐻🐻", + "🐻A🐻Y🐻🐻🐻", + }, results["native"]) +} diff --git a/pkg/automerge/rollback_parity_test.go b/pkg/automerge/rollback_parity_test.go new file mode 100644 index 0000000000..2598149f24 --- /dev/null +++ b/pkg/automerge/rollback_parity_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce the transaction rollback behaviors from the +// upstream Rust owned-transaction suite (rust/automerge/src/transaction/ +// owned_transaction.rs), asserting the native Go and Rust/WASM reference engines +// agree on the number of discarded operations and the resulting state. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestRustMarkPatches_AtEndOfText reproduces mark_patches_at_end_of_text: a mark +// applied at the end of text and loaded incrementally into another document +// produces a single Mark patch through the diff cursor. +func TestRustMarkPatches_AtEndOfText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + author, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, author) + + text, err := author.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "sample")) + _, err = author.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + saved, err := author.Save(ctx) + require.NoError(t, err) + + follower, err := engine.load(ctx, saved, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, follower) + + require.NoError(t, text.Mark(ctx, 5, 6, "bold", markBool(), automerge.MarkExpandAfter)) + _, err = author.Commit(ctx, "mark", commitTime.Add(time.Second)) + require.NoError(t, err) + + incremental, err := author.SaveIncremental(ctx) + require.NoError(t, err) + + require.NoError(t, follower.UpdateDiffCursor(ctx)) + _, err = follower.LoadIncremental(ctx, incremental) + require.NoError(t, err) + + patches, err := follower.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + require.Len(t, reference, 1) + assert.Equal(t, automerge.PatchMark, reference[0].Action) + require.Len(t, reference[0].Marks, 1) + assert.Equal(t, "bold", reference[0].Marks[0].Name) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustTransaction_RollbackDiscardsOps reproduces rollback_discards_ops: a +// rollback with no pending writes discards nothing and preserves prior state. +func TestRustTransaction_RollbackDiscardsOps(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cancelled := make(map[string]uint64) + values := make(map[string]string) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "keep", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "yes"}, + )) + _, err = document.Commit(ctx, "keep", commitTime) + require.NoError(t, err) + + count, err := document.Rollback(ctx) + require.NoError(t, err) + cancelled[engine.name] = count + + value, err := document.Root().Scalar(ctx, "keep") + require.NoError(t, err) + values[engine.name] = value.String + } + + assert.Equal(t, uint64(0), cancelled["reference"]) + assert.Equal(t, "yes", values["reference"]) + assert.Equal(t, cancelled["reference"], cancelled["native"]) + assert.Equal(t, values["reference"], values["native"]) +} + +// TestRustTransaction_RollbackUndoesWrites reproduces rollback_undoes_writes: a +// rollback discards the uncommitted write and reports the discarded op count. +func TestRustTransaction_RollbackUndoesWrites(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cancelled := make(map[string]uint64) + present := make(map[string]bool) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.Root().PutScalar( + ctx, + "gone", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "soon"}, + )) + + count, err := document.Rollback(ctx) + require.NoError(t, err) + cancelled[engine.name] = count + + _, err = document.Root().Scalar(ctx, "gone") + present[engine.name] = err == nil + } + + assert.Equal(t, uint64(1), cancelled["reference"]) + assert.False(t, present["reference"]) + assert.Equal(t, cancelled["reference"], cancelled["native"]) + assert.Equal(t, present["reference"], present["native"]) +} diff --git a/pkg/automerge/save_load_orphans_parity_test.go b/pkg/automerge/save_load_orphans_parity_test.go new file mode 100644 index 0000000000..e4e2881234 --- /dev/null +++ b/pkg/automerge/save_load_orphans_parity_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust orphan-change tests from +// automerge 0.10 (rust/automerge/tests/test_save_load_orphans.rs) against both +// the native Go engine and the Rust/WASM reference engine. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestRustOrphans_LoadIncrementalChangeWithoutDepsThrows reproduces +// load_incremental_change_without_deps_throws: a bare change chunk whose +// dependencies are absent cannot be loaded as a standalone document. +func TestRustOrphans_LoadIncrementalChangeWithoutDepsThrows(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + require.NoError(t, doc.PutString(ctx, "key", "value")) + _, err = doc.Commit(ctx, "value", commitTime) + require.NoError(t, err) + _, err = doc.SaveIncremental(ctx) + require.NoError(t, err) + + require.NoError(t, doc.PutString(ctx, "key", "value2")) + _, err = doc.Commit(ctx, "value2", commitTime.Add(time.Second)) + require.NoError(t, err) + orphan, err := doc.SaveIncremental(ctx) + require.NoError(t, err) + + _, err = engine.load(ctx, orphan, actor(2)) + require.Error(t, err) + }) + } +} diff --git a/pkg/automerge/scalar.go b/pkg/automerge/scalar.go new file mode 100644 index 0000000000..5d0ec2fbc8 --- /dev/null +++ b/pkg/automerge/scalar.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math" +) + +type ( + // ScalarType identifies one Automerge scalar value type. + ScalarType string + + // Scalar preserves the exact type of one Automerge scalar. + Scalar struct { + Type ScalarType + Bool bool + Uint uint64 + Int int64 + Float float64 + String string + Bytes []byte + } + + scalarWire struct { + Type ScalarType `json:"type"` + Bool bool `json:"bool"` + Uint uint64 `json:"uint"` + Int int64 `json:"int"` + Float uint64 `json:"floatBits"` + String string `json:"string"` + Bytes string `json:"bytes"` + } +) + +const ( + ScalarTypeNull ScalarType = "null" + ScalarTypeBoolean ScalarType = "boolean" + ScalarTypeUint ScalarType = "uint" + ScalarTypeInt ScalarType = "int" + ScalarTypeFloat64 ScalarType = "float64" + ScalarTypeString ScalarType = "string" + ScalarTypeBytes ScalarType = "bytes" + ScalarTypeCounter ScalarType = "counter" + ScalarTypeTimestamp ScalarType = "timestamp" +) + +// PutScalar assigns a typed scalar at a key in the root map. +func (d *Document) PutScalar(ctx context.Context, key string, value Scalar) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + encoded, err := encodeScalarWire(value) + if err != nil { + return fmt.Errorf("cannot encode Automerge scalar: %w", err) + } + + if err := d.backend.PutScalar(ctx, rootObject, key, encoded); err != nil { + return fmt.Errorf("cannot put Automerge scalar: %w", err) + } + + return nil +} + +// Scalar returns a typed scalar from a key in the root map. +func (d *Document) Scalar(ctx context.Context, key string) (Scalar, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return Scalar{}, ErrClosed + } + + encoded, err := d.backend.GetScalar(ctx, rootObject, key) + if err != nil { + return Scalar{}, fmt.Errorf("cannot get Automerge scalar: %w", err) + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode Automerge scalar: %w", err) + } + + return value, nil +} + +// Scalars returns every concurrent scalar value at a key in the root map. +func (d *Document) Scalars(ctx context.Context, key string) ([]Scalar, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + encoded, err := d.backend.GetAllScalars(ctx, rootObject, key) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge scalar conflicts: %w", err) + } + + values, err := decodeScalarWires(encoded) + if err != nil { + return nil, fmt.Errorf("cannot decode Automerge scalar conflicts: %w", err) + } + + return values, nil +} + +func encodeScalarWire(value Scalar) ([]byte, error) { + wire := scalarWire{ + Type: value.Type, + Bool: value.Bool, + Uint: value.Uint, + Int: value.Int, + Float: math.Float64bits(value.Float), + String: value.String, + Bytes: hex.EncodeToString(value.Bytes), + } + if !validScalarType(value.Type) { + return nil, fmt.Errorf("unknown scalar type %q", value.Type) + } + + return json.Marshal(wire) +} + +func decodeScalarWire(data []byte) (Scalar, error) { + var wire scalarWire + if err := json.Unmarshal(data, &wire); err != nil { + return Scalar{}, err + } + + if !validScalarType(wire.Type) { + return Scalar{}, fmt.Errorf("unknown scalar type %q", wire.Type) + } + + var bytes []byte + + if wire.Type == ScalarTypeBytes { + var err error + + bytes, err = hex.DecodeString(wire.Bytes) + if err != nil { + return Scalar{}, fmt.Errorf("cannot decode scalar bytes: %w", err) + } + } + + return Scalar{ + Type: wire.Type, + Bool: wire.Bool, + Uint: wire.Uint, + Int: wire.Int, + Float: math.Float64frombits(wire.Float), + String: wire.String, + Bytes: bytes, + }, nil +} + +func decodeScalarWires(data []byte) ([]Scalar, error) { + var encoded []json.RawMessage + if err := json.Unmarshal(data, &encoded); err != nil { + return nil, err + } + + values := make([]Scalar, len(encoded)) + for i, value := range encoded { + decoded, err := decodeScalarWire(value) + if err != nil { + return nil, fmt.Errorf("cannot decode scalar %d: %w", i, err) + } + + values[i] = decoded + } + + return values, nil +} + +func validScalarType(value ScalarType) bool { + switch value { + case ScalarTypeNull, + ScalarTypeBoolean, + ScalarTypeUint, + ScalarTypeInt, + ScalarTypeFloat64, + ScalarTypeString, + ScalarTypeBytes, + ScalarTypeCounter, + ScalarTypeTimestamp: + return true + default: + return false + } +} diff --git a/pkg/automerge/scenario_test.go b/pkg/automerge/scenario_test.go new file mode 100644 index 0000000000..c00080c8d1 --- /dev/null +++ b/pkg/automerge/scenario_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +package automerge_test + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "math" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +type ( + interopScenario struct { + Name string `json:"name"` + Actor string `json:"actor"` + Operations []interopScenarioOperation `json:"operations"` + } + + interopScenarioOperation struct { + Action string `json:"action"` + Path []string `json:"path"` + Key string `json:"key"` + ObjectType automerge.ObjectType `json:"objectType"` + Scalar interopScenarioScalar `json:"scalar"` + Index uint64 `json:"index"` + DeleteCount int32 `json:"deleteCount"` + Text string `json:"text"` + Delta int64 `json:"delta"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` + } + + interopScenarioScalar struct { + Type automerge.ScalarType `json:"type"` + Bool bool `json:"bool"` + Uint uint64 `json:"uint"` + Int int64 `json:"int"` + FloatBits string `json:"floatBits"` + String string `json:"string"` + Bytes string `json:"bytes"` + } +) + +func TestInteropScenario_CoreDataModel(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("testdata/scenarios/core-data-model.json") + require.NoError(t, err) + + var scenario interopScenario + require.NoError(t, json.Unmarshal(data, &scenario)) + actorBytes, err := hex.DecodeString(scenario.Actor) + require.NoError(t, err) + require.Len(t, actorBytes, 16) + + var actorID automerge.ActorID + copy(actorID[:], actorBytes) + + ctx := context.Background() + nativeDocument := runInteropScenario( + t, + ctx, + scenario, + func(ctx context.Context, actorID automerge.ActorID) (*automerge.Document, error) { + return automerge.New(ctx, actorID) + }, + actorID, + ) + closeDocument(t, nativeDocument) + referenceDocument := runInteropScenario( + t, + ctx, + scenario, + automerge.NewReference, + actorID, + ) + closeDocument(t, referenceDocument) + + nativeHeads, err := nativeDocument.Heads(ctx) + require.NoError(t, err) + referenceHeads, err := referenceDocument.Heads(ctx) + require.NoError(t, err) + nativeData, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceData, err := referenceDocument.Save(ctx) + require.NoError(t, err) + assertInteropScenarioResult(t, ctx, nativeDocument) + assertInteropScenarioResult(t, ctx, referenceDocument) + + nativeFromReference, err := automerge.Load( + ctx, + referenceData, + actor(187), + ) + require.NoError(t, err) + closeDocument(t, nativeFromReference) + assertInteropScenarioResult(t, ctx, nativeFromReference) + referenceFromNative, err := automerge.LoadReference( + ctx, + nativeData, + actor(188), + ) + require.NoError(t, err) + closeDocument(t, referenceFromNative) + assertInteropScenarioResult(t, ctx, referenceFromNative) + + response := runOracle( + t, + oracleRequest{ + Action: "runScenario", + Scenario: data, + }, + ) + nativeInspection := runOracle( + t, + oracleRequest{ + Action: "inspectScenario", + Document: base64.StdEncoding.EncodeToString(nativeData), + }, + ) + assert.Equal(t, response.Data, nativeInspection.Data) + assert.Equal( + t, + []string{nativeHeads[0].String()}, + nativeInspection.Heads, + ) + referenceInspection := runOracle( + t, + oracleRequest{ + Action: "inspectScenario", + Document: base64.StdEncoding.EncodeToString(referenceData), + }, + ) + assert.Equal(t, response.Data, referenceInspection.Data) + assert.Equal( + t, + []string{referenceHeads[0].String()}, + referenceInspection.Heads, + ) + + javaScriptData, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + javaScriptDocument, err := automerge.Load(ctx, javaScriptData, actor(186)) + require.NoError(t, err) + closeDocument(t, javaScriptDocument) + assertInteropScenarioResult(t, ctx, javaScriptDocument) + javaScriptReference, err := automerge.LoadReference( + ctx, + javaScriptData, + actor(189), + ) + require.NoError(t, err) + closeDocument(t, javaScriptReference) + assertInteropScenarioResult(t, ctx, javaScriptReference) + javaScriptHeads, err := javaScriptDocument.Heads(ctx) + require.NoError(t, err) + require.Equal(t, response.Heads, []string{javaScriptHeads[0].String()}) +} + +func runInteropScenario( + t *testing.T, + ctx context.Context, + scenario interopScenario, + factory func( + context.Context, + automerge.ActorID, + ) (*automerge.Document, error), + actorID automerge.ActorID, +) *automerge.Document { + t.Helper() + + document, err := factory(ctx, actorID) + require.NoError(t, err) + + objects := map[string]*automerge.Object{"": document.Root()} + texts := make(map[string]*automerge.Text) + + for index, operation := range scenario.Operations { + parent := objects[scenarioPath(operation.Path)] + switch operation.Action { + case "createObject": + require.NotNil(t, parent, "operation %d parent", index) + object, err := parent.CreateObject( + ctx, + operation.Key, + operation.ObjectType, + ) + require.NoError(t, err, "operation %d", index) + + objects[scenarioPath( + append(operation.Path, operation.Key), + )] = object + case "putScalar": + require.NotNil(t, parent, "operation %d parent", index) + require.NoError( + t, + parent.PutScalar(ctx, operation.Key, operation.Scalar.value(t)), + "operation %d", + index, + ) + case "insertScalar": + require.NotNil(t, parent, "operation %d parent", index) + require.NoError( + t, + parent.InsertScalar( + ctx, + operation.Index, + operation.Scalar.value(t), + ), + "operation %d", + index, + ) + case "putScalarAt": + require.NotNil(t, parent, "operation %d parent", index) + require.NoError( + t, + parent.PutScalarAt( + ctx, + operation.Index, + operation.Scalar.value(t), + ), + "operation %d", + index, + ) + case "deleteIndex": + require.NotNil(t, parent, "operation %d parent", index) + require.NoError( + t, + parent.DeleteIndex(ctx, operation.Index), + "operation %d", + index, + ) + case "createText": + require.NotNil(t, parent, "operation %d parent", index) + require.Empty(t, operation.Path) + text, err := document.CreateText(ctx, operation.Key) + require.NoError(t, err, "operation %d", index) + + texts[scenarioPath( + append(operation.Path, operation.Key), + )] = text + case "spliceText": + text := texts[scenarioPath(operation.Path)] + require.NotNil(t, text, "operation %d text", index) + require.NoError( + t, + text.Splice( + ctx, + uint32(operation.Index), + operation.DeleteCount, + operation.Text, + ), + "operation %d", + index, + ) + case "increment": + require.NotNil(t, parent, "operation %d parent", index) + require.NoError( + t, + parent.Increment(ctx, operation.Key, operation.Delta), + "operation %d", + index, + ) + case "commit": + _, err := document.Commit( + ctx, + operation.Message, + time.Unix(operation.Timestamp, 0), + ) + require.NoError(t, err, "operation %d", index) + default: + require.Failf( + t, + "unsupported scenario action", + "operation %d: %q", + index, + operation.Action, + ) + } + } + + return document +} + +func assertInteropScenarioResult( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) { + t.Helper() + + config, err := document.Root().Object(ctx, "config") + require.NoError(t, err) + assertScenarioScalar( + t, + config, + "name", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "Policy πŸ˜€"}, + ) + assertScenarioScalar( + t, + config, + "enabled", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + ) + assertScenarioScalar( + t, + config, + "nothing", + automerge.Scalar{Type: automerge.ScalarTypeNull}, + ) + assertScenarioScalar( + t, + config, + "int", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: -42}, + ) + assertScenarioScalar( + t, + config, + "uint", + automerge.Scalar{Type: automerge.ScalarTypeUint, Uint: 42}, + ) + assertScenarioScalar( + t, + config, + "float64", + automerge.Scalar{Type: automerge.ScalarTypeFloat64, Float: 3.25}, + ) + assertScenarioScalar( + t, + config, + "bytes", + automerge.Scalar{ + Type: automerge.ScalarTypeBytes, + Bytes: []byte{0, 1, 254, 255}, + }, + ) + assertScenarioScalar( + t, + config, + "timestamp", + automerge.Scalar{ + Type: automerge.ScalarTypeTimestamp, + Int: 1_786_147_200_000, + }, + ) + assertScenarioScalar( + t, + config, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 8}, + ) + + items, err := document.Root().Object(ctx, "items") + require.NoError(t, err) + length, err := items.Len(ctx) + require.NoError(t, err) + require.Equal(t, uint64(1), length) + + item, err := items.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, "replaced", item.String) + + text, err := document.Text(ctx, "body") + require.NoError(t, err) + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AXB", value) +} + +func assertScenarioScalar( + t *testing.T, + object *automerge.Object, + key string, + expected automerge.Scalar, +) { + t.Helper() + + actual, err := object.Scalar(context.Background(), key) + require.NoError(t, err) + assertScalarEqual(t, expected, actual) +} + +func (s interopScenarioScalar) value(t *testing.T) automerge.Scalar { + t.Helper() + + bytes, err := hex.DecodeString(s.Bytes) + require.NoError(t, err) + + var floatBits uint64 + if s.FloatBits != "" { + floatBits, err = strconv.ParseUint(s.FloatBits, 10, 64) + require.NoError(t, err) + } + + return automerge.Scalar{ + Type: s.Type, + Bool: s.Bool, + Uint: s.Uint, + Int: s.Int, + Float: math.Float64frombits(floatBits), + String: s.String, + Bytes: bytes, + } +} + +func scenarioPath(path []string) string { + return strings.Join(path, "\x00") +} diff --git a/pkg/automerge/sync_false_positive_parity_test.go b/pkg/automerge/sync_false_positive_parity_test.go new file mode 100644 index 0000000000..dfc79d7a1e --- /dev/null +++ b/pkg/automerge/sync_false_positive_parity_test.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// This file reproduces the upstream Bloom false-positive recovery tests +// (should_handle_false_positive_head and should_handle_chains_of_false_positives +// in rust/automerge/src/sync.rs). A Bloom false positive causes a peer to +// wrongly believe the other already has a change and withhold it; the V2 sync +// protocol must still converge by detecting the missing dependency and +// requesting it. The false positive is located with the reference engine's +// actual Bloom filter (exposed through ReferenceBloomContains) so the scenario +// is deterministic and genuine on the reference engine, while the native engine +// β€” which uses exact head comparison instead of Bloom filters β€” must converge in +// the same topology. + +package automerge_test + +import ( + "context" + "fmt" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// bloomOracle returns a reference document used only to evaluate Bloom filter +// membership. Change hashes are engine-independent, so the same oracle locates +// the false positive for both the native and reference runs. +func bloomOracle(t *testing.T, ctx context.Context) *automerge.Document { + t.Helper() + + oracle, err := automerge.NewReference(ctx, actor(0xB0)) + require.NoError(t, err) + closeDocument(t, oracle) + + return oracle +} + +func unionSortedHeadHex(left, right []string) []string { + seen := make(map[string]struct{}, len(left)+len(right)) + union := make([]string, 0, len(left)+len(right)) + + for _, value := range append(append([]string{}, left...), right...) { + if _, ok := seen[value]; ok { + continue + } + + seen[value] = struct{}{} + union = append(union, value) + } + + sort.Strings(union) + + return union +} + +func headHashes(t *testing.T, ctx context.Context, document *automerge.Document) []automerge.Hash { + t.Helper() + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + return heads +} + +// TestRustSync_ShouldHandleFalsePositiveHead reproduces +// should_handle_false_positive_head: two concurrent changes n1 and n2 are built +// on a shared history where n2 is a false positive in the Bloom filter of {n1}. +// Synchronization must still converge both peers to the union of their heads. +func TestRustSync_ShouldHandleFalsePositiveHead(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + oracle := bloomOracle(t, ctx) + + doc1, err := engine.open(ctx, actor(0xa1)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xd4)) + require.NoError(t, err) + closeDocument(t, doc2) + + for i := int64(0); i < 10; i++ { + putInt(t, ctx, doc1, "x", i, "x", commitTime.Add(time.Duration(i)*time.Second)) + } + + syncQuiescent(t, ctx, readWriteSyncState(t, ctx, doc1), readWriteSyncState(t, ctx, doc2)) + + var n1, n2 *automerge.Document + + for i := 0; ; i++ { + require.Less(t, i, 10000, "no Bloom false positive found") + + candidate1, err := doc1.Fork(ctx, actor(0x11)) + require.NoError(t, err) + + putRoot(t, ctx, candidate1, "x", fmt.Sprintf("%d @ n1", i), "n1", commitTime.Add(time.Hour)) + + candidate2, err := doc1.Fork(ctx, actor(0x22)) + require.NoError(t, err) + + putRoot(t, ctx, candidate2, "x", fmt.Sprintf("%d @ n2", i), "n2", commitTime.Add(time.Hour)) + + n1Heads := headHashes(t, ctx, candidate1) + n2Heads := headHashes(t, ctx, candidate2) + + falsePositive, err := oracle.ReferenceBloomContains(ctx, n1Heads, n2Heads[0]) + require.NoError(t, err) + + if falsePositive { + n1 = candidate1 + n2 = candidate2 + + closeDocument(t, n1) + closeDocument(t, n2) + + break + } + + require.NoError(t, candidate1.Close(ctx)) + require.NoError(t, candidate2.Close(ctx)) + } + + allHeads := unionSortedHeadHex(sortedHeadHex(t, ctx, n1), sortedHeadHex(t, ctx, n2)) + + syncQuiescent(t, ctx, readWriteSyncState(t, ctx, n1), readWriteSyncState(t, ctx, n2)) + + assert.Equal(t, allHeads, sortedHeadHex(t, ctx, n1)) + assert.Equal(t, allHeads, sortedHeadHex(t, ctx, n2)) + }) + } +} + +// TestRustSync_ShouldHandleChainsOfFalsePositives reproduces +// should_handle_chains_of_false_positives: two changes chained on one peer are +// both false positives in the other peer's Bloom filter. Synchronization must +// still converge both peers to the union of their heads. +func TestRustSync_ShouldHandleChainsOfFalsePositives(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + oracle := bloomOracle(t, ctx) + + doc1, err := engine.open(ctx, actor(0xa1)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xd4)) + require.NoError(t, err) + closeDocument(t, doc2) + + for i := int64(0); i < 10; i++ { + putInt(t, ctx, doc1, "x", i, "x", commitTime.Add(time.Duration(i)*time.Second)) + } + + syncQuiescent(t, ctx, readWriteSyncState(t, ctx, doc1), readWriteSyncState(t, ctx, doc2)) + + putInt(t, ctx, doc1, "x", 5, "x5", commitTime.Add(time.Hour)) + bloomSeeds := headHashes(t, ctx, doc1) + + findFalsePositive := func(base *automerge.Document, label string) *automerge.Document { + for i := 0; ; i++ { + require.Less(t, i, 10000, "no Bloom false positive found for %s", label) + + candidate, err := base.Fork(ctx, actor(0x8c)) + require.NoError(t, err) + + putRoot(t, ctx, candidate, "x", fmt.Sprintf("%d %s", i, label), label, commitTime.Add(2*time.Hour)) + + heads := headHashes(t, ctx, candidate) + + falsePositive, err := oracle.ReferenceBloomContains(ctx, bloomSeeds, heads[0]) + require.NoError(t, err) + + if falsePositive { + return candidate + } + + require.NoError(t, candidate.Close(ctx)) + } + } + + chain1 := findFalsePositive(doc2, "at 89abdef") + closeDocument(t, chain1) + + chain2 := findFalsePositive(chain1, "again") + closeDocument(t, chain2) + + putRoot(t, ctx, chain2, "x", "final @ 89abcdef", "final", commitTime.Add(3*time.Hour)) + + allHeads := unionSortedHeadHex(sortedHeadHex(t, ctx, doc1), sortedHeadHex(t, ctx, chain2)) + + syncQuiescent(t, ctx, readWriteSyncState(t, ctx, doc1), readWriteSyncState(t, ctx, chain2)) + + assert.Equal(t, allHeads, sortedHeadHex(t, ctx, doc1)) + assert.Equal(t, allHeads, sortedHeadHex(t, ctx, chain2)) + }) + } +} diff --git a/pkg/automerge/sync_parity_test.go b/pkg/automerge/sync_parity_test.go new file mode 100644 index 0000000000..15df8c7a2b --- /dev/null +++ b/pkg/automerge/sync_parity_test.go @@ -0,0 +1,1003 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust synchronization tests from +// automerge 0.10 (rust/automerge/src/sync.rs) against both the native Go engine +// and the Rust/WASM reference engine. Every scenario drives the same read-only +// and read-write sync protocol through the public Go API and asserts the same +// observable outcomes (which changes each peer receives, peer read-only +// discovery, and quiescence) that the upstream tests assert. + +package automerge_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func readWriteSyncState( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) *automerge.SyncState { + t.Helper() + + state, err := document.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, state) + + return state +} + +func readOnlySyncState( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) *automerge.SyncState { + t.Helper() + + state := readWriteSyncState(t, ctx, document) + require.NoError(t, state.SetReadOnly(ctx, true)) + + return state +} + +// syncQuiescent exchanges messages in both directions until neither peer has +// anything to send, failing if the session does not converge. It mirrors the +// upstream sync() test helper. +func syncQuiescent( + t *testing.T, + ctx context.Context, + left *automerge.SyncState, + right *automerge.SyncState, +) { + t.Helper() + + const maxRounds = 50 + + for range maxRounds { + leftMessage, leftOK, err := left.GenerateMessage(ctx) + require.NoError(t, err) + rightMessage, rightOK, err := right.GenerateMessage(ctx) + require.NoError(t, err) + + if !leftOK && !rightOK { + return + } + + if leftOK { + require.NoError(t, right.ReceiveMessage(ctx, leftMessage)) + } + + if rightOK { + require.NoError(t, left.ReceiveMessage(ctx, rightMessage)) + } + } + + t.Fatalf("sync did not converge within %d rounds", maxRounds) +} + +func rootHasKey( + t *testing.T, + ctx context.Context, + document *automerge.Document, + key string, +) bool { + t.Helper() + + _, err := document.Root().Scalar(ctx, key) + + return err == nil +} + +func putRoot( + t *testing.T, + ctx context.Context, + document *automerge.Document, + key string, + value string, + message string, + when time.Time, +) { + t.Helper() + + require.NoError(t, document.Root().PutScalar( + ctx, + key, + automerge.Scalar{Type: automerge.ScalarTypeString, String: value}, + )) + _, err := document.Commit(ctx, message, when) + require.NoError(t, err) +} + +func putInt( + t *testing.T, + ctx context.Context, + document *automerge.Document, + key string, + value int64, + message string, + when time.Time, +) { + t.Helper() + + require.NoError(t, document.Root().PutScalar( + ctx, + key, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: value}, + )) + _, err := document.Commit(ctx, message, when) + require.NoError(t, err) +} + +// TestRustSync_FirstMessageNoHeadsSendsWholeDoc reproduces +// if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc: when a +// peer starts empty, the other peer's first response carries the entire document +// so the empty peer converges after a single response. +func TestRustSync_FirstMessageNoHeadsSendsWholeDoc(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + empty, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, empty) + + populated, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, populated) + putRoot(t, ctx, populated, "foo", "bar", "seed", commitTime) + + emptyState := readWriteSyncState(t, ctx, empty) + populatedState := readWriteSyncState(t, ctx, populated) + + request, ok, err := emptyState.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, populatedState.ReceiveMessage(ctx, request)) + + response, ok, err := populatedState.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, emptyState.ReceiveMessage(ctx, response)) + + assert.True(t, rootHasKey(t, ctx, empty, "foo"), + "empty peer should receive the whole document in the first response") + + value, err := empty.Root().Scalar(ctx, "foo") + require.NoError(t, err) + assert.Equal(t, "bar", value.String) + }) + } +} + +// TestRustSync_BranchingAndMerging reproduces +// should_handle_lots_of_branching_and_merging: two peers exchange many +// concurrent changes, a third peer's change is merged into one of them, and a +// final synchronization must converge both peers to identical heads. +func TestRustSync_BranchingAndMerging(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0x89)) + require.NoError(t, err) + closeDocument(t, doc2) + + doc3, err := engine.open(ctx, actor(0xfe)) + require.NoError(t, err) + closeDocument(t, doc3) + + putInt(t, ctx, doc1, "x", 0, "x0", commitTime) + _, err = doc2.Merge(ctx, doc1) + require.NoError(t, err) + _, err = doc3.Merge(ctx, doc1) + require.NoError(t, err) + + putInt(t, ctx, doc3, "x", 1, "x1", commitTime.Add(time.Second)) + + for i := int64(1); i < 20; i++ { + when := commitTime.Add(time.Duration(i+1) * time.Second) + putInt(t, ctx, doc1, "n1", i, "n1", when) + putInt(t, ctx, doc2, "n2", i, "n2", when) + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + _, err = doc2.Merge(ctx, doc1) + require.NoError(t, err) + } + + s1 := readWriteSyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + + // doc3's change is concurrent to the last sync heads, forcing the + // slower reconciliation path on the next synchronization. + _, err = doc2.Merge(ctx, doc3) + require.NoError(t, err) + + putInt(t, ctx, doc1, "n1", 100, "n1 final", commitTime.Add(time.Hour)) + putInt(t, ctx, doc2, "n1", 100, "n1 final", commitTime.Add(time.Hour)) + + s1 = readWriteSyncState(t, ctx, doc1) + s2 = readWriteSyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + + assert.Equal(t, sortedHeadHex(t, ctx, doc1), sortedHeadHex(t, ctx, doc2)) + }) + } +} + +// TestRustSync_FirstResponseIsSomeEvenIfNoChanges reproduces +// first_response_is_some_even_if_no_changes. +func TestRustSync_FirstResponseIsSomeEvenIfNoChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + putRoot(t, ctx, doc1, "key", "value", "put", commitTime) + + doc2, err := doc1.Fork(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readWriteSyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + + message, ok, err := s1.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, s2.ReceiveMessage(ctx, message)) + + _, ok, err = s2.GenerateMessage(ctx) + require.NoError(t, err) + assert.True(t, ok, "first response must be sent even with equal heads") + }) + } +} + +// TestRustSync_ShouldNotReplyIfNoDataAfterFirstRound reproduces +// should_not_reply_if_we_have_no_data_after_first_round. +func TestRustSync_ShouldNotReplyIfNoDataAfterFirstRound(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readWriteSyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + + message, ok, err := s1.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, s2.ReceiveMessage(ctx, message)) + + _, ok, err = s2.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok, "first round response expected") + + _, ok, err = s1.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + + _, ok, err = s2.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + }) + } +} + +// TestRustSync_AllowSimultaneousMessages reproduces +// should_allow_simultaneous_messages_during_synchronisation. +func TestRustSync_AllowSimultaneousMessages(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, doc2) + + for i := range 5 { + require.NoError(t, doc1.Root().PutScalar( + ctx, + "x", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i)}, + )) + _, err = doc1.Commit(ctx, "x", commitTime.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + require.NoError(t, doc2.Root().PutScalar( + ctx, + "y", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i)}, + )) + _, err = doc2.Commit(ctx, "y", commitTime.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + } + + s1 := readWriteSyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + + assert.Equal(t, sortedHeadHex(t, ctx, doc1), sortedHeadHex(t, ctx, doc2)) + assert.True(t, rootHasKey(t, ctx, doc1, "y")) + assert.True(t, rootHasKey(t, ctx, doc2, "x")) + }) + } +} + +// TestRustSync_BothReadOnlyOneMakesLocalChanges reproduces +// both_read_only_one_makes_local_changes. +func TestRustSync_BothReadOnlyOneMakesLocalChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readOnlySyncState(t, ctx, doc1) + s2 := readOnlySyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + + putRoot(t, ctx, doc1, "key", "value1", "value1", commitTime) + syncQuiescent(t, ctx, s1, s2) + assert.False(t, rootHasKey(t, ctx, doc2, "key")) + + putRoot(t, ctx, doc1, "key", "value2", "value2", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, s1, s2) + assert.False(t, rootHasKey(t, ctx, doc2, "key")) + + _, ok, err := s1.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + _, ok, err = s2.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + }) + } +} + +// TestRustSync_BothReadOnlySimultaneousChanges reproduces +// both_read_only_simultaneous_changes_during_sync. +func TestRustSync_BothReadOnlySimultaneousChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readOnlySyncState(t, ctx, doc1) + s2 := readOnlySyncState(t, ctx, doc2) + + putRoot(t, ctx, doc1, "x", "1", "x1", commitTime) + putRoot(t, ctx, doc2, "y", "2", "y2", commitTime) + syncQuiescent(t, ctx, s1, s2) + + putRoot(t, ctx, doc1, "x", "3", "x3", commitTime.Add(time.Second)) + putRoot(t, ctx, doc2, "y", "4", "y4", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, s1, s2) + + _, ok, err := s1.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + _, ok, err = s2.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + + assert.False(t, rootHasKey(t, ctx, doc1, "y")) + assert.False(t, rootHasKey(t, ctx, doc2, "x")) + }) + } +} + +// TestRustSync_ReadOnlyPeerNewChangesBetweenRounds reproduces +// read_only_peer_new_changes_between_sync_rounds. +func TestRustSync_ReadOnlyPeerNewChangesBetweenRounds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, doc2) + + putRoot(t, ctx, doc1, "round1", "from_doc1", "r1a", commitTime) + putRoot(t, ctx, doc2, "round1", "from_doc2", "r1b", commitTime) + + s1 := readOnlySyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + assert.True(t, rootHasKey(t, ctx, doc2, "round1")) + + putRoot(t, ctx, doc1, "round2", "new_from_doc1", "r2a", commitTime.Add(time.Second)) + putRoot(t, ctx, doc2, "round2", "new_from_doc2", "r2b", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, s1, s2) + + values, err := doc2.Root().Scalars(ctx, "round2") + require.NoError(t, err) + + found := make(map[string]bool) + for _, value := range values { + found[value.String] = true + } + + assert.True(t, found["new_from_doc1"]) + assert.True(t, found["new_from_doc2"]) + + doc1Values, err := doc1.Root().Scalars(ctx, "round2") + require.NoError(t, err) + require.Len(t, doc1Values, 1) + assert.Equal(t, "new_from_doc1", doc1Values[0].String) + assert.False(t, rootHasKey(t, ctx, doc1, "from_doc2")) + }) + } +} + +// TestRustSync_ReadOnlyPeerConcurrentChanges reproduces +// read_only_peer_concurrent_changes_during_sync. +func TestRustSync_ReadOnlyPeerConcurrentChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readOnlySyncState(t, ctx, doc1) + s2 := readWriteSyncState(t, ctx, doc2) + syncQuiescent(t, ctx, s1, s2) + + require.NoError(t, doc2.Root().PutScalar( + ctx, + "x", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 0}, + )) + _, err = doc2.Commit(ctx, "x", commitTime.Add(time.Second)) + require.NoError(t, err) + + message, ok, err := s2.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, s1.ReceiveMessage(ctx, message)) + + require.NoError(t, doc1.Root().PutScalar( + ctx, + "y", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + _, err = doc1.Commit(ctx, "y", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + syncQuiescent(t, ctx, s1, s2) + + assert.True(t, rootHasKey(t, ctx, doc2, "y")) + assert.False(t, rootHasKey(t, ctx, doc1, "x")) + }) + } +} + +// TestRustSync_SwitchReadWriteToReadOnlyMidSession reproduces +// switch_read_write_to_read_only_mid_session. +func TestRustSync_SwitchReadWriteToReadOnlyMidSession(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + docA, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, docA) + + docB, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, docB) + + putRoot(t, ctx, docA, "from_a", "hello", "a", commitTime) + putRoot(t, ctx, docB, "from_b", "world", "b", commitTime) + + sa := readWriteSyncState(t, ctx, docA) + sb := readWriteSyncState(t, ctx, docB) + syncQuiescent(t, ctx, sa, sb) + assert.Equal(t, sortedHeadHex(t, ctx, docA), sortedHeadHex(t, ctx, docB)) + + require.NoError(t, sa.SetReadOnly(ctx, true)) + + putRoot(t, ctx, docB, "new_from_b", "secret", "nb", commitTime.Add(time.Second)) + putRoot(t, ctx, docA, "new_from_a", "published", "na", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, sa, sb) + + assert.True(t, rootHasKey(t, ctx, docB, "new_from_a")) + assert.False(t, rootHasKey(t, ctx, docA, "new_from_b")) + }) + } +} + +// TestRustSync_SwitchReadOnlyToReadWriteMultipleRounds reproduces +// switch_read_only_to_read_write_with_multiple_rounds. +func TestRustSync_SwitchReadOnlyToReadWriteMultipleRounds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + docA, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, docA) + + docB, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, docB) + + putRoot(t, ctx, docA, "from_a", "initial", "a", commitTime) + + sa := readOnlySyncState(t, ctx, docA) + sb := readWriteSyncState(t, ctx, docB) + + for round := 1; round <= 3; round++ { + putRoot( + t, + ctx, + docB, + roundKey(round), + "from_b", + "b", + commitTime.Add(time.Duration(round)*time.Second), + ) + syncQuiescent(t, ctx, sa, sb) + assert.False(t, rootHasKey(t, ctx, docA, roundKey(round))) + } + + require.NoError(t, sa.SetReadOnly(ctx, false)) + syncQuiescent(t, ctx, sa, sb) + + for round := 1; round <= 3; round++ { + assert.True(t, rootHasKey(t, ctx, docA, roundKey(round))) + } + + assert.Equal(t, sortedHeadHex(t, ctx, docA), sortedHeadHex(t, ctx, docB)) + }) + } +} + +// TestRustSync_ToggleReadOnlyMultipleTimes reproduces +// toggle_read_only_multiple_times. +func TestRustSync_ToggleReadOnlyMultipleTimes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + docA, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, docA) + + docB, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, docB) + + sa := readOnlySyncState(t, ctx, docA) + sb := readWriteSyncState(t, ctx, docB) + + putRoot(t, ctx, docB, "b1", "val", "b1", commitTime) + putRoot(t, ctx, docA, "a1", "val", "a1", commitTime) + syncQuiescent(t, ctx, sa, sb) + assert.True(t, rootHasKey(t, ctx, docB, "a1")) + assert.False(t, rootHasKey(t, ctx, docA, "b1")) + + require.NoError(t, sa.SetReadOnly(ctx, false)) + putRoot(t, ctx, docB, "b2", "val", "b2", commitTime.Add(time.Second)) + putRoot(t, ctx, docA, "a2", "val", "a2", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, sa, sb) + assert.True(t, rootHasKey(t, ctx, docA, "b1")) + assert.True(t, rootHasKey(t, ctx, docA, "b2")) + assert.True(t, rootHasKey(t, ctx, docB, "a2")) + + require.NoError(t, sa.SetReadOnly(ctx, true)) + putRoot(t, ctx, docB, "b3", "val", "b3", commitTime.Add(2*time.Second)) + putRoot(t, ctx, docA, "a3", "val", "a3", commitTime.Add(2*time.Second)) + syncQuiescent(t, ctx, sa, sb) + assert.True(t, rootHasKey(t, ctx, docB, "a3")) + assert.False(t, rootHasKey(t, ctx, docA, "b3")) + + require.NoError(t, sa.SetReadOnly(ctx, false)) + syncQuiescent(t, ctx, sa, sb) + assert.True(t, rootHasKey(t, ctx, docA, "b3")) + assert.Equal(t, sortedHeadHex(t, ctx, docA), sortedHeadHex(t, ctx, docB)) + }) + } +} + +// TestRustSync_BothToggleAfterMultipleReadOnlyRounds reproduces +// both_toggle_after_multiple_read_only_rounds. +func TestRustSync_BothToggleAfterMultipleReadOnlyRounds(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xab)) + require.NoError(t, err) + closeDocument(t, doc1) + + doc2, err := engine.open(ctx, actor(0xcd)) + require.NoError(t, err) + closeDocument(t, doc2) + + s1 := readOnlySyncState(t, ctx, doc1) + s2 := readOnlySyncState(t, ctx, doc2) + + for i := range 5 { + putRoot( + t, + ctx, + doc1, + doc1Round(i), + "v", + "d1", + commitTime.Add(time.Duration(i)*time.Second), + ) + putRoot( + t, + ctx, + doc2, + doc2Round(i), + "v", + "d2", + commitTime.Add(time.Duration(i)*time.Second), + ) + syncQuiescent(t, ctx, s1, s2) + } + + for i := range 5 { + assert.False(t, rootHasKey(t, ctx, doc1, doc2Round(i))) + assert.False(t, rootHasKey(t, ctx, doc2, doc1Round(i))) + } + + require.NoError(t, s1.SetReadOnly(ctx, false)) + require.NoError(t, s2.SetReadOnly(ctx, false)) + syncQuiescent(t, ctx, s1, s2) + + for i := range 5 { + assert.True(t, rootHasKey(t, ctx, doc1, doc2Round(i))) + assert.True(t, rootHasKey(t, ctx, doc2, doc1Round(i))) + } + + assert.Equal(t, sortedHeadHex(t, ctx, doc1), sortedHeadHex(t, ctx, doc2)) + }) + } +} + +// TestRustSync_ReadOnlyPublisherToMultipleConsumers reproduces +// read_only_publisher_to_multiple_consumers. +func TestRustSync_ReadOnlyPublisherToMultipleConsumers(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + r, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, r) + + a, err := engine.open(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, a) + + b, err := engine.open(ctx, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, b) + + putRoot(t, ctx, r, "from_r", "hello", "r", commitTime) + + srA := readOnlySyncState(t, ctx, r) + saR := readWriteSyncState(t, ctx, a) + syncQuiescent(t, ctx, srA, saR) + assert.True(t, rootHasKey(t, ctx, a, "from_r")) + + putRoot(t, ctx, a, "from_a", "world", "a", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, srA, saR) + assert.False(t, rootHasKey(t, ctx, r, "from_a")) + + srB := readOnlySyncState(t, ctx, r) + sbR := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, srB, sbR) + assert.True(t, rootHasKey(t, ctx, b, "from_r")) + assert.False(t, rootHasKey(t, ctx, b, "from_a")) + }) + } +} + +// TestRustSync_ReadOnlyFullyConnectedTriangle reproduces +// read_only_fully_connected_triangle. +func TestRustSync_ReadOnlyFullyConnectedTriangle(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + r, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, r) + + a, err := engine.open(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, a) + + b, err := engine.open(ctx, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, b) + + putRoot(t, ctx, r, "from_r", "r_val", "r", commitTime) + putRoot(t, ctx, a, "from_a", "a_val", "a", commitTime) + putRoot(t, ctx, b, "from_b", "b_val", "b", commitTime) + rHeads := sortedHeadHex(t, ctx, r) + + srA := readOnlySyncState(t, ctx, r) + saR := readWriteSyncState(t, ctx, a) + syncQuiescent(t, ctx, srA, saR) + + srB := readOnlySyncState(t, ctx, r) + sbR := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, srB, sbR) + + assert.True(t, rootHasKey(t, ctx, a, "from_r")) + assert.True(t, rootHasKey(t, ctx, b, "from_r")) + + saB := readWriteSyncState(t, ctx, a) + sbA := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, saB, sbA) + + for _, document := range []*automerge.Document{a, b} { + assert.True(t, rootHasKey(t, ctx, document, "from_a")) + assert.True(t, rootHasKey(t, ctx, document, "from_b")) + assert.True(t, rootHasKey(t, ctx, document, "from_r")) + } + + assert.Equal(t, sortedHeadHex(t, ctx, a), sortedHeadHex(t, ctx, b)) + assert.Equal(t, rHeads, sortedHeadHex(t, ctx, r)) + assert.False(t, rootHasKey(t, ctx, r, "from_a")) + assert.False(t, rootHasKey(t, ctx, r, "from_b")) + }) + } +} + +// TestRustSync_StaleSharedHeadsAfterReadOnlySync reproduces +// stale_shared_heads_after_read_only_sync. +func TestRustSync_StaleSharedHeadsAfterReadOnlySync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + r, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, r) + + a, err := engine.open(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, a) + + b, err := engine.open(ctx, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, b) + + for i := range 10 { + require.NoError(t, r.Root().PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i)}, + )) + _, err = r.Commit(ctx, "counter", commitTime.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + } + + putRoot(t, ctx, a, "from_a", "a_val", "a", commitTime) + + srA := readOnlySyncState(t, ctx, r) + saR := readWriteSyncState(t, ctx, a) + syncQuiescent(t, ctx, srA, saR) + assert.True(t, rootHasKey(t, ctx, a, "counter")) + + saB := readWriteSyncState(t, ctx, a) + sbA := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, saB, sbA) + assert.True(t, rootHasKey(t, ctx, b, "counter")) + assert.True(t, rootHasKey(t, ctx, b, "from_a")) + + srB := readOnlySyncState(t, ctx, r) + sbR := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, srB, sbR) + + assert.False(t, rootHasKey(t, ctx, r, "from_a")) + assert.True(t, rootHasKey(t, ctx, b, "counter")) + assert.True(t, rootHasKey(t, ctx, b, "from_a")) + }) + } +} + +// TestRustSync_ReadOnlyPeerReceivesSameChangesFromTwoPeers reproduces +// read_only_peer_receives_same_changes_from_two_peers. +func TestRustSync_ReadOnlyPeerReceivesSameChangesFromTwoPeers(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + r, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, r) + + a, err := engine.open(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, a) + + b, err := engine.open(ctx, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, b) + + putRoot(t, ctx, r, "from_r", "r_val", "r", commitTime) + putRoot(t, ctx, a, "from_a", "a_val", "a", commitTime) + putRoot(t, ctx, b, "from_b", "b_val", "b", commitTime) + + saB := readWriteSyncState(t, ctx, a) + sbA := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, saB, sbA) + assert.Equal(t, sortedHeadHex(t, ctx, a), sortedHeadHex(t, ctx, b)) + + rHeads := sortedHeadHex(t, ctx, r) + + srA := readOnlySyncState(t, ctx, r) + saR := readWriteSyncState(t, ctx, a) + syncQuiescent(t, ctx, srA, saR) + assert.True(t, rootHasKey(t, ctx, a, "from_r")) + assert.Equal(t, rHeads, sortedHeadHex(t, ctx, r)) + + srB := readOnlySyncState(t, ctx, r) + sbR := readWriteSyncState(t, ctx, b) + syncQuiescent(t, ctx, srB, sbR) + assert.True(t, rootHasKey(t, ctx, b, "from_r")) + + assert.Equal(t, rHeads, sortedHeadHex(t, ctx, r)) + assert.False(t, rootHasKey(t, ctx, r, "from_a")) + assert.False(t, rootHasKey(t, ctx, r, "from_b")) + + putRoot(t, ctx, r, "from_r_2", "new", "r2", commitTime.Add(time.Second)) + syncQuiescent(t, ctx, srA, saR) + assert.True(t, rootHasKey(t, ctx, a, "from_r_2")) + syncQuiescent(t, ctx, srB, sbR) + assert.True(t, rootHasKey(t, ctx, b, "from_r_2")) + }) + } +} + +func roundKey(round int) string { + return "round" + string(rune('0'+round)) +} + +func doc1Round(index int) string { + return "doc1_r" + string(rune('0'+index)) +} + +func doc2Round(index int) string { + return "doc2_r" + string(rune('0'+index)) +} diff --git a/pkg/automerge/test_rs_core_parity_test.go b/pkg/automerge/test_rs_core_parity_test.go new file mode 100644 index 0000000000..de11e30f4b --- /dev/null +++ b/pkg/automerge/test_rs_core_parity_test.go @@ -0,0 +1,1775 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce individual upstream Rust integration tests +// from automerge 0.10 (rust/automerge/tests/test.rs) against both the native +// Go engine and the Rust/WASM reference engine. Every scenario runs identically +// on both engines and asserts that the observable materialized state, causal +// heads, and cross-engine reloads agree. Because both engines produce identical +// change hashes for identical operation sequences, matching heads across the +// real Rust engine and the native engine also guarantees identical conflict +// structure by construction. + +package automerge_test + +import ( + "context" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +type rustParityEngine struct { + name string + open func(context.Context, automerge.ActorID) (*automerge.Document, error) + load func(context.Context, []byte, automerge.ActorID) (*automerge.Document, error) +} + +func rustParityEngines() []rustParityEngine { + return []rustParityEngine{ + {"native", automerge.New, automerge.Load}, + {"reference", automerge.NewReference, automerge.LoadReference}, + } +} + +func sortedHeadHex( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) []string { + t.Helper() + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + hex := make([]string, len(heads)) + for index, head := range heads { + hex[index] = head.String() + } + + sort.Strings(hex) + + return hex +} + +func sortedCounterValues( + t *testing.T, + ctx context.Context, + object *automerge.Object, + key string, +) []int64 { + t.Helper() + + values, err := object.Scalars(ctx, key) + require.NoError(t, err) + + result := make([]int64, len(values)) + for index, value := range values { + result[index] = value.Int + } + + sort.Slice(result, func(left, right int) bool { + return result[left] < result[right] + }) + + return result +} + +func sortedStringValues( + t *testing.T, + ctx context.Context, + object *automerge.Object, + key string, +) []string { + t.Helper() + + values, err := object.Scalars(ctx, key) + require.NoError(t, err) + + result := make([]string, len(values)) + for index, value := range values { + result[index] = value.String + } + + sort.Strings(result) + + return result +} + +// TestRust_RepeatedListAssignmentResolvesConflict reproduces +// repeated_list_assignment_which_resolves_conflict_not_ignored. +func TestRust_RepeatedListAssignmentResolvesConflict(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + + list, err := doc1.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 123}, + )) + _, err = doc1.Commit(ctx, "insert", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + list2, err := doc2.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, list2.PutScalarAt( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 456}, + )) + _, err = doc2.Commit(ctx, "put 456", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + require.NoError(t, list.PutScalarAt( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 789}, + )) + _, err = doc1.Commit(ctx, "put 789", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(1), length) + + winner, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(789), winner.Int) + + results[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, results["reference"], results["native"]) +} + +// TestRust_AddIncrementsOnlyToPreceededValues reproduces +// add_increments_only_to_preceeded_values. +func TestRust_AddIncrementsOnlyToPreceededValues(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + require.NoError(t, doc1.Root().PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 0}, + )) + require.NoError(t, doc1.Root().Increment(ctx, "counter", 1)) + _, err = doc1.Commit(ctx, "doc1 counter", commitTime) + require.NoError(t, err) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + require.NoError(t, doc2.Root().PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 0}, + )) + require.NoError(t, doc2.Root().Increment(ctx, "counter", 3)) + _, err = doc2.Commit(ctx, "doc2 counter", commitTime) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + assert.Equal(t, []int64{1, 3}, sortedCounterValues(t, ctx, doc1.Root(), "counter")) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_AssignmentConflictsOfDifferentTypes reproduces +// assignment_conflicts_of_different_types. +func TestRust_AssignmentConflictsOfDifferentTypes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + require.NoError(t, doc1.Root().PutScalar( + ctx, + "field", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "string"}, + )) + _, err = doc1.Commit(ctx, "string", commitTime) + require.NoError(t, err) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + _, err = doc2.Root().CreateObject(ctx, "field", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = doc2.Commit(ctx, "list", commitTime) + require.NoError(t, err) + + doc3, err := engine.open(ctx, actor(3)) + require.NoError(t, err) + closeDocument(t, doc3) + _, err = doc3.Root().CreateObject(ctx, "field", automerge.ObjectTypeMap) + require.NoError(t, err) + _, err = doc3.Commit(ctx, "map", commitTime) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + _, err = doc1.Merge(ctx, doc3) + require.NoError(t, err) + + // The highest-actor operation wins; actor(3) created a map. + winner, err := doc1.Root().Object(ctx, "field") + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, winner.Type) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_ChangesWithinConflictingMapField reproduces +// changes_within_conflicting_map_field. +func TestRust_ChangesWithinConflictingMapField(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + require.NoError(t, doc1.Root().PutScalar( + ctx, + "field", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "string"}, + )) + _, err = doc1.Commit(ctx, "string", commitTime) + require.NoError(t, err) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + inner, err := doc2.Root().CreateObject(ctx, "field", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, inner.PutScalar( + ctx, + "innerKey", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 42}, + )) + _, err = doc2.Commit(ctx, "map", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + // actor(2) wins; the winning value is the map with innerKey = 42. + winner, err := doc1.Root().Object(ctx, "field") + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, winner.Type) + value, err := winner.Scalar(ctx, "innerKey") + require.NoError(t, err) + assert.Equal(t, int64(42), value.Int) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_ChangesWithinConflictingListElement reproduces +// changes_within_conflicting_list_element. +func TestRust_ChangesWithinConflictingListElement(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + list1, err := doc1.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list1.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "hello"}, + )) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + map1, err := list1.PutObjectAt(ctx, 0, automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, map1.PutScalar( + ctx, + "map1", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + )) + require.NoError(t, map1.PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + _, err = doc1.Commit(ctx, "doc1 map", commitTime.Add(time.Second)) + require.NoError(t, err) + + list2, err := doc2.Root().Object(ctx, "list") + require.NoError(t, err) + map2, err := list2.PutObjectAt(ctx, 0, automerge.ObjectTypeMap) + require.NoError(t, err) + _, err = doc2.Commit(ctx, "doc2 map", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + require.NoError(t, map2.PutScalar( + ctx, + "map2", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + )) + require.NoError(t, map2.PutScalar( + ctx, + "key", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + _, err = doc2.Commit(ctx, "doc2 values", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + // actor(2)'s map wins with key = 2 and map2 = true. + winner, err := list1.ObjectAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, winner.Type) + key, err := winner.Scalar(ctx, "key") + require.NoError(t, err) + assert.Equal(t, int64(2), key.Int) + + flag, err := winner.Scalar(ctx, "map2") + require.NoError(t, err) + assert.True(t, flag.Bool) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_ConcurrentlyAssignedNestedMapsShouldNotMerge reproduces +// concurrently_assigned_nested_maps_should_not_merge. +func TestRust_ConcurrentlyAssignedNestedMapsShouldNotMerge(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + config1, err := doc1.Root().CreateObject(ctx, "config", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, config1.PutScalar( + ctx, + "background", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "blue"}, + )) + _, err = doc1.Commit(ctx, "doc1 config", commitTime) + require.NoError(t, err) + + doc2, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + config2, err := doc2.Root().CreateObject(ctx, "config", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, config2.PutScalar( + ctx, + "logo_url", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "logo.png"}, + )) + _, err = doc2.Commit(ctx, "doc2 config", commitTime) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + // The two maps do not merge; the winning map keeps exactly one key. + winner, err := doc1.Root().Object(ctx, "config") + require.NoError(t, err) + keys, err := winner.Keys(ctx) + require.NoError(t, err) + assert.Len(t, keys, 1) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_ConcurrentDeletionOfSameListElement reproduces +// concurrent_deletion_of_same_list_element. +func TestRust_ConcurrentDeletionOfSameListElement(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + list1, err := doc1.Root().CreateObject(ctx, "birds", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list1.InsertValues(ctx, 0, []automerge.Value{ + hydratedString("albatross"), + hydratedString("buzzard"), + hydratedString("cormorant"), + })) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + list2, err := doc2.Root().Object(ctx, "birds") + require.NoError(t, err) + + require.NoError(t, list1.DeleteIndex(ctx, 1)) + _, err = doc1.Commit(ctx, "doc1 delete", commitTime.Add(time.Second)) + require.NoError(t, err) + require.NoError(t, list2.DeleteIndex(ctx, 1)) + _, err = doc2.Commit(ctx, "doc2 delete", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + values := listStrings(t, ctx, list1) + assert.Equal(t, []string{"albatross", "cormorant"}, values) + + results[engine.name] = values + } + + assert.Equal(t, results["reference"], results["native"]) +} + +// TestRust_ConcurrentUpdatesAtDifferentLevels reproduces +// concurrent_updates_at_different_levels. +func TestRust_ConcurrentUpdatesAtDifferentLevels(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + animals, err := doc1.Root().CreateObject(ctx, "animals", automerge.ObjectTypeMap) + require.NoError(t, err) + birds, err := animals.CreateObject(ctx, "birds", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, birds.PutScalar( + ctx, + "pink", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "flamingo"}, + )) + require.NoError(t, birds.PutScalar( + ctx, + "black", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "starling"}, + )) + mammals, err := animals.CreateObject(ctx, "mammals", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, mammals.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "badger"}, + )) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + require.NoError(t, birds.PutScalar( + ctx, + "brown", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "sparrow"}, + )) + _, err = doc1.Commit(ctx, "doc1 update", commitTime.Add(time.Second)) + require.NoError(t, err) + + animals2, err := doc2.Root().Object(ctx, "animals") + require.NoError(t, err) + require.NoError(t, animals2.DeleteKey(ctx, "birds")) + _, err = doc2.Commit(ctx, "doc2 delete", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + // birds was deleted concurrently; only mammals remains under animals. + mergedAnimals, err := doc1.Root().Object(ctx, "animals") + require.NoError(t, err) + keys, err := mergedAnimals.Keys(ctx) + require.NoError(t, err) + assert.Equal(t, []string{"mammals"}, keys) + + mergedMammals, err := mergedAnimals.Object(ctx, "mammals") + require.NoError(t, err) + assert.Equal(t, []string{"badger"}, listStrings(t, ctx, mergedMammals)) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_ConcurrentUpdatesOfConcurrentlyDeletedObjects reproduces +// concurrent_updates_of_concurrently_deleted_objects. +func TestRust_ConcurrentUpdatesOfConcurrentlyDeletedObjects(t *testing.T) { + t.Parallel() + + ctx := context.Background() + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + birds, err := doc1.Root().CreateObject(ctx, "birds", automerge.ObjectTypeMap) + require.NoError(t, err) + blackbird, err := birds.CreateObject(ctx, "blackbird", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, blackbird.PutScalar( + ctx, + "feathers", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "black"}, + )) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + require.NoError(t, birds.DeleteKey(ctx, "blackbird")) + _, err = doc1.Commit(ctx, "doc1 delete", commitTime.Add(time.Second)) + require.NoError(t, err) + + birds2, err := doc2.Root().Object(ctx, "birds") + require.NoError(t, err) + blackbird2, err := birds2.Object(ctx, "blackbird") + require.NoError(t, err) + require.NoError(t, blackbird2.PutScalar( + ctx, + "beak", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "orange"}, + )) + _, err = doc2.Commit(ctx, "doc2 update", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + // The deletion wins; birds becomes an empty map. + mergedBirds, err := doc1.Root().Object(ctx, "birds") + require.NoError(t, err) + length, err := mergedBirds.Len(ctx) + require.NoError(t, err) + assert.Zero(t, length) + + heads[engine.name] = sortedHeadHex(t, ctx, doc1) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_InsertionConsistentWithCausality reproduces +// insertion_consistent_with_causality. +func TestRust_InsertionConsistentWithCausality(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + list1, err := doc1.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list1.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "four"}, + )) + _, err = doc1.Commit(ctx, "four", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + list2, err := doc2.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, list2.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "three"}, + )) + _, err = doc2.Commit(ctx, "three", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + require.NoError(t, list1.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "two"}, + )) + _, err = doc1.Commit(ctx, "two", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + _, err = doc2.Merge(ctx, doc1) + require.NoError(t, err) + require.NoError(t, list2.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "one"}, + )) + _, err = doc2.Commit(ctx, "one", commitTime.Add(3*time.Second)) + require.NoError(t, err) + + values := listStrings(t, ctx, list2) + assert.Equal(t, []string{"one", "two", "three", "four"}, values) + + results[engine.name] = values + } + + assert.Equal(t, results["reference"], results["native"]) +} + +// TestRust_SaveRestoreComplex1 reproduces save_restore_complex1. +func TestRust_SaveRestoreComplex1(t *testing.T) { + t.Parallel() + + ctx := context.Background() + titles := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + todos, err := doc1.Root().CreateObject(ctx, "todos", automerge.ObjectTypeList) + require.NoError(t, err) + firstTodo, err := todos.InsertObject(ctx, 0, automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, firstTodo.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "water plants"}, + )) + require.NoError(t, firstTodo.PutScalar( + ctx, + "done", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: false}, + )) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + todos2, err := doc2.Root().Object(ctx, "todos") + require.NoError(t, err) + firstTodo2, err := todos2.ObjectAt(ctx, 0) + require.NoError(t, err) + require.NoError(t, firstTodo2.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "weed plants"}, + )) + _, err = doc2.Commit(ctx, "weed", commitTime.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, firstTodo.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "kill plants"}, + )) + _, err = doc1.Commit(ctx, "kill", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + saved, err := doc1.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, saved, actor(3)) + require.NoError(t, err) + closeDocument(t, reloaded) + + reloadedTodos, err := reloaded.Root().Object(ctx, "todos") + require.NoError(t, err) + reloadedTodo, err := reloadedTodos.ObjectAt(ctx, 0) + require.NoError(t, err) + done, err := reloadedTodo.Scalar(ctx, "done") + require.NoError(t, err) + assert.False(t, done.Bool) + + values := sortedStringValues(t, ctx, reloadedTodo, "title") + assert.Equal(t, []string{"kill plants", "weed plants"}, values) + + titles[engine.name] = values + } + + assert.Equal(t, titles["reference"], titles["native"]) +} + +// TestRust_SaveRestoreComplexTransactional reproduces +// save_restore_complex_transactional. The Rust test groups its writes inside +// explicit transactions; the observable outcome is identical to a single +// grouped commit, which is what the Go engine exposes. +func TestRust_SaveRestoreComplexTransactional(t *testing.T) { + t.Parallel() + + ctx := context.Background() + titles := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + todos, err := doc1.Root().CreateObject(ctx, "todos", automerge.ObjectTypeList) + require.NoError(t, err) + firstTodo, err := todos.InsertObject(ctx, 0, automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, firstTodo.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "water plants"}, + )) + require.NoError(t, firstTodo.PutScalar( + ctx, + "done", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: false}, + )) + _, err = doc1.Commit(ctx, "transaction", commitTime) + require.NoError(t, err) + + data, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + todos2, err := doc2.Root().Object(ctx, "todos") + require.NoError(t, err) + firstTodo2, err := todos2.ObjectAt(ctx, 0) + require.NoError(t, err) + require.NoError(t, firstTodo2.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "weed plants"}, + )) + _, err = doc2.Commit(ctx, "transaction", commitTime.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, firstTodo.PutScalar( + ctx, + "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "kill plants"}, + )) + _, err = doc1.Commit(ctx, "transaction", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + + saved, err := doc1.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, saved, actor(3)) + require.NoError(t, err) + closeDocument(t, reloaded) + + reloadedTodos, err := reloaded.Root().Object(ctx, "todos") + require.NoError(t, err) + reloadedTodo, err := reloadedTodos.ObjectAt(ctx, 0) + require.NoError(t, err) + done, err := reloadedTodo.Scalar(ctx, "done") + require.NoError(t, err) + assert.False(t, done.Bool) + + values := sortedStringValues(t, ctx, reloadedTodo, "title") + assert.Equal(t, []string{"kill plants", "weed plants"}, values) + + titles[engine.name] = values + } + + assert.Equal(t, titles["reference"], titles["native"]) +} + +// TestRust_BigList reproduces big_list. The upstream test inspects the patch +// stream; the interoperable behavior is the resulting document state, which +// this test verifies is a list of N+1 map objects that survives a cross-engine +// save/load. +func TestRust_BigList(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const count = 128 + + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + list, err := doc.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = doc.Commit(ctx, "create list", commitTime) + require.NoError(t, err) + + for index := range count + 1 { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeNull}, + )) + } + + for index := range count + 1 { + _, err := list.PutObjectAt(ctx, uint64(index), automerge.ObjectTypeMap) + require.NoError(t, err) + } + + _, err = doc.Commit(ctx, "populate", commitTime.Add(time.Second)) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(count+1), length) + + element, err := list.ObjectAt(ctx, count) + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, element.Type) + + saved, err := doc.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, saved, actor(2)) + require.NoError(t, err) + closeDocument(t, reloaded) + reloadedList, err := reloaded.Root().Object(ctx, "list") + require.NoError(t, err) + reloadedLength, err := reloadedList.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(count+1), reloadedLength) + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_InvalidIndex reproduces invalid_index. +func TestRust_InvalidIndex(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + list, err := doc.Root().CreateObject(ctx, "a", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list.InsertScalar( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + require.NoError(t, list.PutScalarAt( + ctx, + 0, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + + value, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + assert.Equal(t, int64(2), value.Int) + + require.Error(t, list.InsertScalar( + ctx, + 2, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + require.Error(t, list.PutScalarAt( + ctx, + 2, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + require.Error(t, list.InsertScalar( + ctx, + 100, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + require.Error(t, list.PutScalarAt( + ctx, + 100, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + }) + } +} + +// TestRust_HasOurChanges reproduces has_our_changes: two peers with concurrent +// changes synchronize until each has received the other's changes. +func TestRust_HasOurChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + left, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, left) + require.NoError(t, left.Root().PutScalar( + ctx, + "a", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + leftHash, err := left.Commit(ctx, "a", commitTime) + require.NoError(t, err) + + right, err := engine.open(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, right) + require.NoError(t, right.Root().PutScalar( + ctx, + "b", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 2}, + )) + rightHash, err := right.Commit(ctx, "b", commitTime) + require.NoError(t, err) + + leftToRight, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftToRight) + + rightToLeft, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightToLeft) + + syncBothDirections(t, ctx, leftToRight, rightToLeft) + + rightHasLeft, err := right.HasHeads(ctx, []automerge.Hash{leftHash}) + require.NoError(t, err) + assert.True(t, rightHasLeft) + + leftHasRight, err := left.HasHeads(ctx, []automerge.Hash{rightHash}) + require.NoError(t, err) + assert.True(t, leftHasRight) + + assert.Equal( + t, + sortedHeadHex(t, ctx, left), + sortedHeadHex(t, ctx, right), + ) + }) + } +} + +// TestRust_LoadIncrementalWithCommonHead reproduces +// make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head. +func TestRust_LoadIncrementalWithCommonHead(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + require.NoError(t, doc1.Root().PutScalar( + ctx, + "string", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "hello"}, + )) + _, err = doc1.Commit(ctx, "hello", commitTime) + require.NoError(t, err) + + base, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, base, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + + doc3, err := engine.load(ctx, base, actor(3)) + require.NoError(t, err) + closeDocument(t, doc3) + + heads1, err := doc1.Heads(ctx) + require.NoError(t, err) + require.Len(t, heads1, 1) + + require.NoError(t, doc1.Root().PutScalar( + ctx, + "concurrent1", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "123"}, + )) + hashB, err := doc1.Commit(ctx, "concurrent1", commitTime.Add(time.Second)) + require.NoError(t, err) + + saved1, err := doc1.Save(ctx) + require.NoError(t, err) + _, err = doc3.LoadIncremental(ctx, saved1) + require.NoError(t, err) + headsC, err := doc3.Heads(ctx) + require.NoError(t, err) + require.Len(t, headsC, 1) + assert.Equal(t, hashB.String(), headsC[0].String()) + + require.NoError(t, doc2.Root().PutScalar( + ctx, + "concurrent2", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "abc"}, + )) + hashD, err := doc2.Commit(ctx, "concurrent2", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc2.Merge(ctx, doc1) + require.NoError(t, err) + mergedHeads := sortedHeadHex(t, ctx, doc2) + require.Len(t, mergedHeads, 2) + assert.Contains(t, mergedHeads, hashB.String()) + assert.Contains(t, mergedHeads, hashD.String()) + + saved2, err := doc2.Save(ctx) + require.NoError(t, err) + _, err = doc3.LoadIncremental(ctx, saved2) + require.NoError(t, err) + assert.Equal(t, mergedHeads, sortedHeadHex(t, ctx, doc3)) + }) + } +} + +// TestRust_RegressionNthMiscount reproduces regression_nth_miscount. +func TestRust_RegressionNthMiscount(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const count = 30 + + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + list, err := doc.Root().CreateObject(ctx, "listval", automerge.ObjectTypeList) + require.NoError(t, err) + + for index := range count { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeNull}, + )) + element, err := list.PutObjectAt(ctx, uint64(index), automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, element.PutScalar( + ctx, + "test", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(index)}, + )) + } + + _, err = doc.Commit(ctx, "populate", commitTime) + require.NoError(t, err) + + for index := range count { + element, err := list.ObjectAt(ctx, uint64(index)) + require.NoError(t, err) + assert.Equal(t, automerge.ObjectTypeMap, element.Type) + value, err := element.Scalar(ctx, "test") + require.NoError(t, err) + assert.Equal(t, int64(index), value.Int) + } + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_RegressionNthMiscountSmaller reproduces +// regression_nth_miscount_smaller. B is the op-tree node size (16 upstream). +func TestRust_RegressionNthMiscountSmaller(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const count = 16 * 4 + + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + list, err := doc.Root().CreateObject(ctx, "listval", automerge.ObjectTypeList) + require.NoError(t, err) + + for index := range count { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeNull}, + )) + require.NoError(t, list.PutScalarAt( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(index)}, + )) + } + + _, err = doc.Commit(ctx, "populate", commitTime) + require.NoError(t, err) + + for index := range count { + value, err := list.ScalarAt(ctx, uint64(index)) + require.NoError(t, err) + assert.Equal(t, int64(index), value.Int) + } + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_RegressionInsertOpid reproduces regression_insert_opid: interleaved +// insert-then-overwrite operations round-trip through a cross-engine reload +// with every list value preserved. +func TestRust_RegressionInsertOpid(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const count = 30 + + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + list, err := doc.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + _, err = doc.Commit(ctx, "create list", commitTime) + require.NoError(t, err) + + for index := range count + 1 { + require.NoError(t, list.InsertScalar( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeNull}, + )) + require.NoError(t, list.PutScalarAt( + ctx, + uint64(index), + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(index)}, + )) + } + + _, err = doc.Commit(ctx, "populate", commitTime.Add(time.Second)) + require.NoError(t, err) + + saved, err := doc.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, saved, actor(2)) + require.NoError(t, err) + closeDocument(t, reloaded) + reloadedList, err := reloaded.Root().Object(ctx, "list") + require.NoError(t, err) + + for index := range count + 1 { + original, err := list.ScalarAt(ctx, uint64(index)) + require.NoError(t, err) + roundTripped, err := reloadedList.ScalarAt(ctx, uint64(index)) + require.NoError(t, err) + assert.Equal(t, int64(index), original.Int) + assert.Equal(t, original.Int, roundTripped.Int) + } + + heads[engine.name] = sortedHeadHex(t, ctx, doc) + } + + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRust_RollbackWithSeveralActors reproduces rollback_with_several_actors: +// uncommitted edits by a third actor are discarded, leaving the document +// byte-identical to the state it was forked from. +func TestRust_RollbackWithSeveralActors(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc1, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, doc1) + text1, err := doc1.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text1.Splice( + ctx, + 0, + 0, + "the sly fox jumped over the lazy dog", + )) + mapA1, err := doc1.Root().CreateObject(ctx, "map_a", automerge.ObjectTypeMap) + require.NoError(t, err) + require.NoError(t, mapA1.PutScalar( + ctx, + "key1", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value1a"}, + )) + require.NoError(t, mapA1.PutScalar( + ctx, + "key2", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value2a"}, + )) + _, err = doc1.Commit(ctx, "doc1", commitTime) + require.NoError(t, err) + + doc2, err := doc1.Fork(ctx, actor(0xcc)) + require.NoError(t, err) + closeDocument(t, doc2) + text2, err := doc2.Text(ctx, "text") + require.NoError(t, err) + require.NoError(t, text2.Splice(ctx, 8, 3, "monkey")) + require.NoError(t, text2.Splice(ctx, 36, 3, "pig")) + mapC2, err := doc2.Root().CreateObject(ctx, "map_c", automerge.ObjectTypeMap) + require.NoError(t, err) + mapA2, err := doc2.Root().Object(ctx, "map_a") + require.NoError(t, err) + require.NoError(t, mapA2.PutScalar( + ctx, + "key2", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value2c"}, + )) + require.NoError(t, mapA2.PutScalar( + ctx, + "key3", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value3c"}, + )) + require.NoError(t, mapC2.PutScalar( + ctx, + "key1", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + _, err = doc2.Commit(ctx, "doc2", commitTime.Add(time.Second)) + require.NoError(t, err) + + doc3, err := doc2.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, doc3) + text3, err := doc3.Text(ctx, "text") + require.NoError(t, err) + require.NoError(t, text3.Splice(ctx, 8, 5, "zebra")) + mapB3, err := doc3.Root().CreateObject(ctx, "map_b", automerge.ObjectTypeMap) + require.NoError(t, err) + mapA3, err := doc3.Root().Object(ctx, "map_a") + require.NoError(t, err) + require.NoError(t, mapA3.PutScalar( + ctx, + "key1", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value3b"}, + )) + require.NoError(t, mapA3.PutScalar( + ctx, + "key3", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value3b"}, + )) + require.NoError(t, mapB3.PutScalar( + ctx, + "key1", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"}, + )) + + _, err = doc3.Rollback(ctx) + require.NoError(t, err) + + assert.Equal(t, sortedHeadHex(t, ctx, doc2), sortedHeadHex(t, ctx, doc3)) + + doc2Save, err := doc2.Save(ctx) + require.NoError(t, err) + doc3Save, err := doc3.Save(ctx) + require.NoError(t, err) + assert.Equal(t, doc2Save, doc3Save) + }) + } +} + +// TestRust_SaveWithOpsReferencingActorsOnlyViaDelete reproduces +// save_with_ops_which_reference_actors_only_via_delete: a merged delete op +// references a fork's actor only through successors, and the document must still +// save and reload. +func TestRust_SaveWithOpsReferencingActorsOnlyViaDelete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + require.NoError(t, doc.Root().PutScalar( + ctx, + "a", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + _, err = doc.Commit(ctx, "put a", commitTime) + require.NoError(t, err) + + forked, err := doc.Fork(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, forked) + require.NoError(t, forked.Root().DeleteKey(ctx, "a")) + _, err = forked.Commit(ctx, "delete a", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc.Merge(ctx, forked) + require.NoError(t, err) + + saved, err := doc.Save(ctx) + require.NoError(t, err) + + nativeReload, err := automerge.Load(ctx, saved, actor(3)) + require.NoError(t, err) + closeDocument(t, nativeReload) + + referenceReload, err := automerge.LoadReference(ctx, saved, actor(4)) + require.NoError(t, err) + closeDocument(t, referenceReload) + + for _, reloaded := range []*automerge.Document{nativeReload, referenceReload} { + length, err := reloaded.Root().Len(ctx) + require.NoError(t, err) + assert.Zero(t, length) + } + }) + } +} + +func sortedScalarsAt( + t *testing.T, + ctx context.Context, + object *automerge.Object, + index uint64, +) []string { + t.Helper() + + values, err := object.ScalarsAt(ctx, index) + require.NoError(t, err) + + result := make([]string, len(values)) + for i, value := range values { + result[i] = fmt.Sprintf("%s:%d:%d", value.Type, value.Int, value.Uint) + } + + sort.Strings(result) + + return result +} + +// TestRust_ListCounterDel reproduces list_counter_del: three actors write +// conflicting counters (and one integer) to the same list elements, increments +// are applied and merged, and the elements are deleted. The conflicting value +// sets and lengths are compared directly against the reference engine. +func TestRust_ListCounterDel(t *testing.T) { + t.Parallel() + + ctx := context.Background() + index1 := make(map[string][]string) + index2 := make(map[string][]string) + + for _, engine := range rustParityEngines() { + doc1, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc1) + list1, err := doc1.Root().CreateObject(ctx, "list", automerge.ObjectTypeList) + require.NoError(t, err) + require.NoError(t, list1.InsertValues(ctx, 0, []automerge.Value{ + hydratedString("a"), + hydratedString("b"), + hydratedString("c"), + })) + _, err = doc1.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + base, err := doc1.Save(ctx) + require.NoError(t, err) + doc2, err := engine.load(ctx, base, actor(2)) + require.NoError(t, err) + closeDocument(t, doc2) + list2, err := doc2.Root().Object(ctx, "list") + require.NoError(t, err) + doc3, err := engine.load(ctx, base, actor(3)) + require.NoError(t, err) + closeDocument(t, doc3) + list3, err := doc3.Root().Object(ctx, "list") + require.NoError(t, err) + + counter := func(value int64) automerge.Scalar { + return automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: value} + } + + require.NoError(t, list1.PutScalarAt(ctx, 1, counter(0))) + require.NoError(t, list2.PutScalarAt(ctx, 1, counter(10))) + require.NoError(t, list3.PutScalarAt(ctx, 1, counter(100))) + + require.NoError(t, list1.PutScalarAt(ctx, 2, counter(0))) + require.NoError(t, list2.PutScalarAt(ctx, 2, counter(10))) + require.NoError(t, list3.PutScalarAt( + ctx, + 2, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 100}, + )) + + require.NoError(t, list1.IncrementAt(ctx, 1, 1)) + require.NoError(t, list1.IncrementAt(ctx, 2, 1)) + + _, err = doc1.Commit(ctx, "doc1", commitTime.Add(time.Second)) + require.NoError(t, err) + _, err = doc2.Commit(ctx, "doc2", commitTime.Add(time.Second)) + require.NoError(t, err) + _, err = doc3.Commit(ctx, "doc3", commitTime.Add(time.Second)) + require.NoError(t, err) + + _, err = doc1.Merge(ctx, doc2) + require.NoError(t, err) + _, err = doc1.Merge(ctx, doc3) + require.NoError(t, err) + + require.NoError(t, list1.IncrementAt(ctx, 1, 1)) + require.NoError(t, list1.IncrementAt(ctx, 2, 1)) + _, err = doc1.Commit(ctx, "increments", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + index1[engine.name] = sortedScalarsAt(t, ctx, list1, 1) + index2[engine.name] = sortedScalarsAt(t, ctx, list1, 2) + + require.NoError(t, list1.DeleteIndex(ctx, 2)) + _, err = doc1.Commit(ctx, "delete 2", commitTime.Add(3*time.Second)) + require.NoError(t, err) + length, err := list1.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), length) + + saved, err := doc1.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, saved, actor(4)) + require.NoError(t, err) + closeDocument(t, reloaded) + reloadedList, err := reloaded.Root().Object(ctx, "list") + require.NoError(t, err) + reloadedLength, err := reloadedList.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(2), reloadedLength) + + require.NoError(t, list1.DeleteIndex(ctx, 1)) + _, err = doc1.Commit(ctx, "delete 1", commitTime.Add(4*time.Second)) + require.NoError(t, err) + length, err = list1.Len(ctx) + require.NoError(t, err) + assert.Equal(t, uint64(1), length) + } + + assert.Equal(t, index1["reference"], index1["native"]) + assert.Equal(t, index2["reference"], index2["native"]) +} + +// TestRust_SimpleBadSaveload reproduces simple_bad_saveload: an empty commit +// interleaved with real changes must not corrupt the save/load round trip. The +// upstream test reassigns the same value; because both engines treat a repeated +// equal assignment as a no-op, this variant uses a distinct value for the +// second write so the empty commit remains the interleaved change. +func TestRust_SimpleBadSaveload(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + require.NoError(t, doc.Root().PutScalar( + ctx, + "count", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 0}, + )) + _, err = doc.Commit(ctx, "count 0", commitTime) + require.NoError(t, err) + + _, err = doc.EmptyCommit(ctx, "empty", commitTime.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, doc.Root().PutScalar( + ctx, + "count", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 1}, + )) + _, err = doc.Commit(ctx, "count 1", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + saved, err := doc.Save(ctx) + require.NoError(t, err) + + for _, load := range []func(context.Context, []byte, automerge.ActorID) (*automerge.Document, error){ + automerge.Load, + automerge.LoadReference, + } { + reloaded, err := load(ctx, saved, actor(2)) + require.NoError(t, err) + closeDocument(t, reloaded) + value, err := reloaded.Root().Scalar(ctx, "count") + require.NoError(t, err) + assert.Equal(t, int64(1), value.Int) + } + }) + } +} + +// TestRust_BadChangeOnOptreeNodeBoundary reproduces +// bad_change_on_optree_node_boundary: a document grown across an op-tree node +// boundary is saved, reloaded elsewhere, then a further change is transferred +// and the result still saves and reloads with matching state. +func TestRust_BadChangeOnOptreeNodeBoundary(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const iterations = 15 + + for _, engine := range rustParityEngines() { + t.Run(engine.name, func(t *testing.T) { + t.Parallel() + + doc, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, doc) + require.NoError(t, doc.Root().PutScalar( + ctx, + "a", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "z"}, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "b", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 0}, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "c", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: 0}, + )) + _, err = doc.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + for i := range iterations { + require.NoError(t, doc.Root().PutScalar( + ctx, + "a", + automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: strings.Repeat("a", i), + }, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "b", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i + 1)}, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "c", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(i + 1)}, + )) + _, err = doc.Commit( + ctx, + "iterate", + commitTime.Add(time.Duration(i+1)*time.Second), + ) + require.NoError(t, err) + } + + saved, err := doc.Save(ctx) + require.NoError(t, err) + other, err := engine.load(ctx, saved, actor(2)) + require.NoError(t, err) + closeDocument(t, other) + + final := iterations + 2 + require.NoError(t, doc.Root().PutScalar( + ctx, + "a", + automerge.Scalar{ + Type: automerge.ScalarTypeString, + String: strings.Repeat("a", final), + }, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "b", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(final)}, + )) + require.NoError(t, doc.Root().PutScalar( + ctx, + "c", + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(final)}, + )) + _, err = doc.Commit(ctx, "final", commitTime.Add(time.Hour)) + require.NoError(t, err) + + _, err = other.Merge(ctx, doc) + require.NoError(t, err) + + transferred, err := other.Save(ctx) + require.NoError(t, err) + reloaded, err := engine.load(ctx, transferred, actor(3)) + require.NoError(t, err) + closeDocument(t, reloaded) + + value, err := reloaded.Root().Scalar(ctx, "b") + require.NoError(t, err) + assert.Equal(t, int64(final), value.Int) + assert.Equal(t, sortedHeadHex(t, ctx, doc), sortedHeadHex(t, ctx, other)) + }) + } +} + +func syncBothDirections( + t *testing.T, + ctx context.Context, + leftToRight *automerge.SyncState, + rightToLeft *automerge.SyncState, +) { + t.Helper() + + for range 20 { + quiet := true + + message, ok, err := leftToRight.GenerateMessage(ctx) + require.NoError(t, err) + + if ok { + quiet = false + + require.NoError(t, rightToLeft.ReceiveMessage(ctx, message)) + } + + message, ok, err = rightToLeft.GenerateMessage(ctx) + require.NoError(t, err) + + if ok { + quiet = false + + require.NoError(t, leftToRight.ReceiveMessage(ctx, message)) + } + + if quiet { + return + } + } +} diff --git a/pkg/automerge/testdata/fixtures/counter_value_is_ok.automerge b/pkg/automerge/testdata/fixtures/counter_value_is_ok.automerge new file mode 100644 index 0000000000..fdc598968d Binary files /dev/null and b/pkg/automerge/testdata/fixtures/counter_value_is_ok.automerge differ diff --git a/pkg/automerge/testdata/scenarios/core-data-model.json b/pkg/automerge/testdata/scenarios/core-data-model.json new file mode 100644 index 0000000000..4491e3b093 --- /dev/null +++ b/pkg/automerge/testdata/scenarios/core-data-model.json @@ -0,0 +1,27 @@ +{ + "name": "core-data-model", + "actor": "0102030405060708090a0b0c0d0e0f10", + "operations": [ + {"action": "createObject", "path": [], "key": "config", "objectType": "map"}, + {"action": "putScalar", "path": ["config"], "key": "name", "scalar": {"type": "string", "string": "Policy πŸ˜€"}}, + {"action": "putScalar", "path": ["config"], "key": "enabled", "scalar": {"type": "boolean", "bool": true}}, + {"action": "putScalar", "path": ["config"], "key": "nothing", "scalar": {"type": "null"}}, + {"action": "putScalar", "path": ["config"], "key": "int", "scalar": {"type": "int", "int": -42}}, + {"action": "putScalar", "path": ["config"], "key": "uint", "scalar": {"type": "uint", "uint": 42}}, + {"action": "putScalar", "path": ["config"], "key": "float64", "scalar": {"type": "float64", "floatBits": "4614500768194494464"}}, + {"action": "putScalar", "path": ["config"], "key": "bytes", "scalar": {"type": "bytes", "bytes": "0001feff"}}, + {"action": "putScalar", "path": ["config"], "key": "timestamp", "scalar": {"type": "timestamp", "int": 1786147200000}}, + {"action": "putScalar", "path": ["config"], "key": "counter", "scalar": {"type": "counter", "int": 5}}, + {"action": "createObject", "path": [], "key": "items", "objectType": "list"}, + {"action": "insertScalar", "path": ["items"], "index": 0, "scalar": {"type": "string", "string": "first"}}, + {"action": "insertScalar", "path": ["items"], "index": 1, "scalar": {"type": "string", "string": "second"}}, + {"action": "putScalarAt", "path": ["items"], "index": 1, "scalar": {"type": "string", "string": "replaced"}}, + {"action": "deleteIndex", "path": ["items"], "index": 0}, + {"action": "createText", "path": [], "key": "body"}, + {"action": "spliceText", "path": ["body"], "index": 0, "deleteCount": 0, "text": "AπŸ˜€B"}, + {"action": "commit", "message": "create model", "timestamp": 1786147200}, + {"action": "increment", "path": ["config"], "key": "counter", "delta": 3}, + {"action": "spliceText", "path": ["body"], "index": 1, "deleteCount": 2, "text": "X"}, + {"action": "commit", "message": "update model", "timestamp": 1786147201} + ] +} diff --git a/pkg/automerge/testdata/upstream-parity.json b/pkg/automerge/testdata/upstream-parity.json new file mode 100644 index 0000000000..2d32dc5541 --- /dev/null +++ b/pkg/automerge/testdata/upstream-parity.json @@ -0,0 +1,8973 @@ +{ + "schemaVersion": 1, + "sources": { + "rust": { + "package": "automerge", + "version": "0.10.0", + "gitTag": "rust/automerge-0.10.0", + "gitCommit": "a4f584c86358dd07f83f36708573e1c8d1bd8161", + "crateChecksum": "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633" + }, + "javascript": { + "package": "@automerge/automerge", + "version": "3.4.0", + "gitTag": "js/automerge-3.4.0", + "gitCommit": "f8b0911dc9d86265dd62934b7dc782571e3a7fcb", + "npmIntegrity": "sha512-THmghtTNGGt2xsI0pM3o1i3PM8oZKcYFgOj25FOzW7l6e94SQOivNtCwy6xc0I8hVJsQSSotoBNs+yk/9hM2dg==" + } + }, + "tests": [ + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:iife", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "iife", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:node_cjs_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "node_cjs_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:node_cjs_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "node_cjs_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:node_esm_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "node_esm_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:node_esm_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "node_esm_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:vite_fullfat:vite_build_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "vite_fullfat:vite_build_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:vite_fullfat:vite_dev_server_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "vite_fullfat:vite_dev_server_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:vite_iife_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "vite_iife_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:vite_slim:vite_build_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "vite_slim:vite_build_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:vite_slim:vite_dev_server_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "vite_slim:vite_dev_server_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:webpack_cjs_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "webpack_cjs_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:webpack_cjs_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "webpack_cjs_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:webpack_esm_fullfat", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "webpack_esm_fullfat", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:webpack_esm_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "webpack_esm_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:workerd", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "workerd", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript-packaging:packaging_tests/run.mjs:360:workerd_slim", + "source": "javascript-packaging", + "file": "packaging_tests/run.mjs", + "line": 360, + "name": "workerd_slim", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript package exports, WASM loading, or bundler/runtime integration rather than Go CRDT behavior." + }, + { + "id": "javascript:anonymize_test.ts:5:returns a loadable document with anonymized data and matching history shape", + "source": "javascript", + "file": "anonymize_test.ts", + "line": 5, + "name": "returns a loadable document with anonymized data and matching history shape", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:12:should init clone and free", + "source": "javascript", + "file": "basic_test.ts", + "line": 12, + "name": "should init clone and free", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:132:handle basic sets over many changes", + "source": "javascript", + "file": "basic_test.ts", + "line": 132, + "name": "handle basic sets over many changes", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "One thousand randomized nested-map changes produce identical values and change hashes in Go and Rust." + }, + { + "id": "javascript:basic_test.ts:183:handle overwrites to values", + "source": "javascript", + "file": "basic_test.ts", + "line": 183, + "name": "handle overwrites to values", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Multiple assignments in one change resolve to the final value in both engines." + }, + { + "id": "javascript:basic_test.ts:200:handle set with object value", + "source": "javascript", + "file": "basic_test.ts", + "line": 200, + "name": "handle set with object value", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_NestedMapsAndListsMatchReference" + ], + "rationale": "Nested map values are authored independently by Go and Rust and cross-loaded." + }, + { + "id": "javascript:basic_test.ts:21:should be able to make a view with specifc heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 21, + "name": "should be able to make a view with specifc heads", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:210:handle simple list creation", + "source": "javascript", + "file": "basic_test.ts", + "line": 210, + "name": "handle simple list creation", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_LoadedObjectRemainsEditable" + ], + "rationale": "An empty list is created, committed, saved, loaded, and resolved as the same object." + }, + { + "id": "javascript:basic_test.ts:216:handle simple lists", + "source": "javascript", + "file": "basic_test.ts", + "line": 216, + "name": "handle simple lists", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_NestedMapsAndListsMatchReference", + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "List insertion, indexed reads, replacement, deletion, change encoding, and cross-engine application are exercised." + }, + { + "id": "javascript:basic_test.ts:238:handle simple lists", + "source": "javascript", + "file": "basic_test.ts", + "line": 238, + "name": "handle simple lists", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_NestedMapsAndListsMatchReference", + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "List insertion, indexed reads, replacement, deletion, change encoding, and cross-engine application are exercised." + }, + { + "id": "javascript:basic_test.ts:248:handle text", + "source": "javascript", + "file": "basic_test.ts", + "line": 248, + "name": "handle text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestInteropScenario_CoreDataModel" + ], + "rationale": "The same text creation, UTF-16 splice, save, cross-load, and materialization scenario runs independently in Go, Rust, and JavaScript." + }, + { + "id": "javascript:basic_test.ts:260:have many list methods", + "source": "javascript", + "file": "basic_test.ts", + "line": 260, + "name": "have many list methods", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_RandomListParity" + ], + "rationale": "Randomized indexed insertion, replacement, and deletion execute identically and produce identical change hashes in Go and Rust." + }, + { + "id": "javascript:basic_test.ts:285:allows access to the backend", + "source": "javascript", + "file": "basic_test.ts", + "line": 285, + "name": "allows access to the backend", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:292:lists and text have indexof", + "source": "javascript", + "file": "basic_test.ts", + "line": 292, + "name": "lists and text have indexof", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:300:get change metadata", + "source": "javascript", + "file": "basic_test.ts", + "line": 300, + "name": "get change metadata", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "Go validates the actor, sequence, start operation, timestamp, message, dependencies, operations, and hash of an official change." + }, + { + "id": "javascript:basic_test.ts:327:should work in unstable", + "source": "javascript", + "file": "basic_test.ts", + "line": 327, + "name": "should work in unstable", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:33:should allow you to change a clone of a view", + "source": "javascript", + "file": "basic_test.ts", + "line": 33, + "name": "should allow you to change a clone of a view", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:342:it should handle conflicts the same in merges as with loads", + "source": "javascript", + "file": "basic_test.ts", + "line": 342, + "name": "it should handle conflicts the same in merges as with loads", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Native and Rust independently construct and merge identical histories, then compare all scalar conflicts." + }, + { + "id": "javascript:basic_test.ts:384:should not copy the patchcallback", + "source": "javascript", + "file": "basic_test.ts", + "line": 384, + "name": "should not copy the patchcallback", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:396:should generate a hash", + "source": "javascript", + "file": "basic_test.ts", + "line": 396, + "name": "should generate a hash", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestInteropScenario_CoreDataModel", + "TestConformance_JavaScriptPreservesGoChanges" + ], + "rationale": "All engines generate heads, and transferred Go changes retain their original hash through JavaScript." + }, + { + "id": "javascript:basic_test.ts:412:behave like arrays", + "source": "javascript", + "file": "basic_test.ts", + "line": 412, + "name": "behave like arrays", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:44:handle basic set and read on root object", + "source": "javascript", + "file": "basic_test.ts", + "line": 44, + "name": "handle basic set and read on root object", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_StringParity", + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "Root scalar writes and reads execute against native and Rust engines and cross-load in both directions." + }, + { + "id": "javascript:basic_test.ts:526:should obtain the same conflicts, regardless of merge order", + "source": "javascript", + "file": "basic_test.ts", + "line": 526, + "name": "should obtain the same conflicts, regardless of merge order", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Left-first and right-first merges converge to identical conflicts in native and Rust engines." + }, + { + "id": "javascript:basic_test.ts:558:should return null for scalar values", + "source": "javascript", + "file": "basic_test.ts", + "line": 558, + "name": "should return null for scalar values", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:567:should return _root for the root object", + "source": "javascript", + "file": "basic_test.ts", + "line": 567, + "name": "should return _root for the root object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:571:should return non-null for map, list, text, and objects", + "source": "javascript", + "file": "basic_test.ts", + "line": 571, + "name": "should return non-null for map, list, text, and objects", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:578:can load a doc without checking the heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 578, + "name": "can load a doc without checking the heads", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:587:can diff a document with before and hafter heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 587, + "name": "can diff a document with before and hafter heads", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:606:should be the same as saveIncremental since heads of the last saveIncremental", + "source": "javascript", + "file": "basic_test.ts", + "line": 606, + "name": "should be the same as saveIncremental since heads of the last saveIncremental", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Repeated incremental saves emit only new changes, and a full save advances the incremental cursor." + }, + { + "id": "javascript:basic_test.ts:631:splice", + "source": "javascript", + "file": "basic_test.ts", + "line": 631, + "name": "splice", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:638:updateText", + "source": "javascript", + "file": "basic_test.ts", + "line": 638, + "name": "updateText", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:645:getCursor", + "source": "javascript", + "file": "basic_test.ts", + "line": 645, + "name": "getCursor", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:650:getCursorPosition", + "source": "javascript", + "file": "basic_test.ts", + "line": 650, + "name": "getCursorPosition", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:656:mark/unmark", + "source": "javascript", + "file": "basic_test.ts", + "line": 656, + "name": "mark/unmark", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:66:should be able to insert and delete a large number of properties", + "source": "javascript", + "file": "basic_test.ts", + "line": 66, + "name": "should be able to insert and delete a large number of properties", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:673:marks", + "source": "javascript", + "file": "basic_test.ts", + "line": 673, + "name": "marks", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:678:marksAt", + "source": "javascript", + "file": "basic_test.ts", + "line": 678, + "name": "marksAt", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:685:should return true if the document in question has all the heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 685, + "name": "should return true if the document in question has all the heads", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Native and Rust report true for current, historical, and empty head sets." + }, + { + "id": "javascript:basic_test.ts:692:should return false if the document does not have the heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 692, + "name": "should return false if the document does not have the heads", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Native and Rust report false for an unknown change hash." + }, + { + "id": "javascript:basic_test.ts:703:should return the correct history", + "source": "javascript", + "file": "basic_test.ts", + "line": 703, + "name": "should return the correct history", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:730:should return a decoded representation of the change", + "source": "javascript", + "file": "basic_test.ts", + "line": 730, + "name": "should return a decoded representation of the change", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "Every operation and metadata field in a JavaScript change is decoded and asserted before byte-compatible re-encoding." + }, + { + "id": "javascript:basic_test.ts:765:should return stats about the document", + "source": "javascript", + "file": "basic_test.ts", + "line": 765, + "name": "should return stats about the document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:778:should return the document at its correct heads", + "source": "javascript", + "file": "basic_test.ts", + "line": 778, + "name": "should return the document at its correct heads", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_HistoricalReadsMatchReference" + ], + "rationale": "Historical scalar and text values are materialized at the first head after later changes exist." + }, + { + "id": "javascript:basic_test.ts:792:should treat any class which has the correct symbol as a ImmutableString", + "source": "javascript", + "file": "basic_test.ts", + "line": 792, + "name": "should treat any class which has the correct symbol as a ImmutableString", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString/RawString wrapper type predicate and backwards-compatibility export; a binding-specific scalar wrapper." + }, + { + "id": "javascript:basic_test.ts:82:can detect an automerge doc with isAutomerge()", + "source": "javascript", + "file": "basic_test.ts", + "line": 82, + "name": "can detect an automerge doc with isAutomerge()", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:basic_test.ts:821:should export RawString and isRawString for backwards compatibility", + "source": "javascript", + "file": "basic_test.ts", + "line": 821, + "name": "should export RawString and isRawString for backwards compatibility", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString/RawString wrapper type predicate and backwards-compatibility export; a binding-specific scalar wrapper." + }, + { + "id": "javascript:basic_test.ts:829:should export a predicate to check if something is an immutablestring", + "source": "javascript", + "file": "basic_test.ts", + "line": 829, + "name": "should export a predicate to check if something is an immutablestring", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString/RawString wrapper type predicate and backwards-compatibility export; a binding-specific scalar wrapper." + }, + { + "id": "javascript:basic_test.ts:842:it should be able to roll back a transaction", + "source": "javascript", + "file": "basic_test.ts", + "line": 842, + "name": "it should be able to roll back a transaction", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_RollbackMatchesReference" + ], + "rationale": "Pending scalar and object operations are rolled back, committed values and heads remain unchanged, and Rust reports the same cancelled operation count." + }, + { + "id": "javascript:basic_test.ts:855:it should be able to handle ints and floats at their limits", + "source": "javascript", + "file": "basic_test.ts", + "line": 855, + "name": "it should be able to handle ints and floats at their limits", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "The scalar differential includes maximum unsigned integers, minimum signed integers, infinity, and NaN with bit-exact float comparison." + }, + { + "id": "javascript:basic_test.ts:94:it should recursively freeze the document if requested", + "source": "javascript", + "file": "basic_test.ts", + "line": 94, + "name": "it should recursively freeze the document if requested", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:block_test.ts:135:emits insert patches with ImmutableString for attribute updatese", + "source": "javascript", + "file": "block_test.ts", + "line": 135, + "name": "emits insert patches with ImmutableString for attribute updatese", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) patch value wrapper; a binding-specific scalar type." + }, + { + "id": "javascript:block_test.ts:179:should update marks", + "source": "javascript", + "file": "block_test.ts", + "line": 179, + "name": "should update marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/should_update_marks" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:18:can split a block", + "source": "javascript", + "file": "block_test.ts", + "line": 18, + "name": "can split a block", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_BlockAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently insert and populate block marker maps at matching rich-text positions." + }, + { + "id": "javascript:block_test.ts:200:allows configuring the default expand value of created marks", + "source": "javascript", + "file": "block_test.ts", + "line": 200, + "name": "allows configuring the default expand value of created marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/configuring_default_expand" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:225:should allow overriding the default expand on a per mark basis", + "source": "javascript", + "file": "block_test.ts", + "line": 225, + "name": "should allow overriding the default expand on a per mark basis", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/override_default_expand_per_mark" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:250:should allow omitting any part of the update spans config", + "source": "javascript", + "file": "block_test.ts", + "line": 250, + "name": "should allow omitting any part of the update spans config", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_OmittingConfigParts" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:291:when loading blocks", + "source": "javascript", + "file": "block_test.ts", + "line": 291, + "name": "when loading blocks", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) block attribute wrapper round-trip; a binding-specific scalar type." + }, + { + "id": "javascript:block_test.ts:308:when loading spans", + "source": "javascript", + "file": "block_test.ts", + "line": 308, + "name": "when loading spans", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript ImmutableString (RawString) block attribute wrapper round-trip; a binding-specific scalar type." + }, + { + "id": "javascript:block_test.ts:331:updates the document even if the only change was to a block attribute", + "source": "javascript", + "file": "block_test.ts", + "line": 331, + "name": "updates the document even if the only change was to a block attribute", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/updates_document_on_block_attribute_change" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:371:should show historical marks", + "source": "javascript", + "file": "block_test.ts", + "line": 371, + "name": "should show historical marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_ShowHistoricalMarks" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:388:can allow small values in block attributes", + "source": "javascript", + "file": "block_test.ts", + "line": 388, + "name": "can allow small values in block attributes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/small_values_in_block_attributes" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:block_test.ts:61:can join a block", + "source": "javascript", + "file": "block_test.ts", + "line": 61, + "name": "can join a block", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_BlockAuthoringMatchesReference" + ], + "rationale": "Go and Rust delete a block marker, preserve surrounding text, and cross-load identical spans." + }, + { + "id": "javascript:block_test.ts:81:allows updating all blocks at once", + "source": "javascript", + "file": "block_test.ts", + "line": 81, + "name": "allows updating all blocks at once", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSBlock_UpdateSpansScenarios/allows_updating_all_blocks_at_once" + ], + "rationale": "Cross-engine block behavior reproduced against the reference." + }, + { + "id": "javascript:bundle_test.ts:27:should allow getting the list of changes in a bundle", + "source": "javascript", + "file": "bundle_test.ts", + "line": 27, + "name": "should allow getting the list of changes in a bundle", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:bundle_test.ts:5:should allow saving and loading a bundle", + "source": "javascript", + "file": "bundle_test.ts", + "line": 5, + "name": "should allow saving and loading a bundle", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:bundle_test.ts:56:should show the dependencies of a bundle", + "source": "javascript", + "file": "bundle_test.ts", + "line": 56, + "name": "should show the dependencies of a bundle", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:change_at.ts:22:should leave multiple heads intact on empty changes", + "source": "javascript", + "file": "change_at.ts", + "line": 22, + "name": "should leave multiple heads intact on empty changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:change_at.ts:47:should return the heads of the change document from changeAt", + "source": "javascript", + "file": "change_at.ts", + "line": 47, + "name": "should return the heads of the change document from changeAt", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:change_at.ts:6:should be able to change a doc at a prior state", + "source": "javascript", + "file": "change_at.ts", + "line": 6, + "name": "should be able to change a doc at a prior state", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:change_at.ts:76:materializes a historical nested edit after a concurrent list insertion", + "source": "javascript", + "file": "change_at.ts", + "line": 76, + "name": "materializes a historical nested edit after a concurrent list insertion", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:change_time.ts:18:should allow user provided timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 18, + "name": "should allow user provided timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "An explicit timestamp is decoded unchanged from native and Rust changes." + }, + { + "id": "javascript:change_time.ts:27:should allow no timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 27, + "name": "should allow no timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "A zero time records the protocol no-timestamp value in native and Rust changes." + }, + { + "id": "javascript:change_time.ts:37:should default to current timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 37, + "name": "should default to current timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_EmptyCommitTimeParity" + ], + "rationale": "EmptyCommitNow records a current Unix-seconds timestamp in native and Rust changes." + }, + { + "id": "javascript:change_time.ts:48:should allow user provided timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 48, + "name": "should allow user provided timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_EmptyCommitTimeParity" + ], + "rationale": "An explicit timestamp is preserved on empty native and Rust changes." + }, + { + "id": "javascript:change_time.ts:57:should allow no timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 57, + "name": "should allow no timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_EmptyCommitTimeParity", + "TestConformance_NativeParsesJavaScriptEmptyChange" + ], + "rationale": "Zero-timestamp empty changes decode and round-trip with JavaScript and Rust." + }, + { + "id": "javascript:change_time.ts:7:should default to current timestamp", + "source": "javascript", + "file": "change_time.ts", + "line": 7, + "name": "should default to current timestamp", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CommitTimeParity" + ], + "rationale": "CommitNow records a current Unix-seconds timestamp in native and Rust changes." + }, + { + "id": "javascript:conflicts.ts:100:should allow updating values inside a conflicted list", + "source": "javascript", + "file": "conflicts.ts", + "line": 100, + "name": "should allow updating values inside a conflicted list", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:conflicts.ts:5:should not allow updating values inside a conflict outside of the change callback", + "source": "javascript", + "file": "conflicts.ts", + "line": 5, + "name": "should not allow updating values inside a conflict outside of the change callback", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:conflicts.ts:56:should allow updating values inside a conflicted map", + "source": "javascript", + "file": "conflicts.ts", + "line": 56, + "name": "should allow updating values inside a conflicted map", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:cursors.ts:122:should allow dates from an existing document to be used in another document", + "source": "javascript", + "file": "cursors.ts", + "line": 122, + "name": "should allow dates from an existing document to be used in another document", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_DatesFlowBetweenDocuments" + ], + "rationale": "A JavaScript Date read from one document as a timestamp scalar is written into another document through the native engine and re-read as a Date by JavaScript, in both a map and a list." + }, + { + "id": "javascript:cursors.ts:135:getCursorPosition should work", + "source": "javascript", + "file": "cursors.ts", + "line": 135, + "name": "getCursorPosition should work", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorMatchesReference" + ], + "rationale": "Cursor bytes and resolved positions are compared directly with Rust." + }, + { + "id": "javascript:cursors.ts:153:getCursor should respect heads", + "source": "javascript", + "file": "cursors.ts", + "line": 153, + "name": "getCursor should respect heads", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSCursors_GetCursorRespectsHeads" + ], + "rationale": "Cursors created against a historical view resolve to the same positions on both engines." + }, + { + "id": "javascript:cursors.ts:178:should allow for usage of start/end cursors", + "source": "javascript", + "file": "cursors.ts", + "line": 178, + "name": "should allow for usage of start/end cursors", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Start and end cursor bytes and resolved UTF-16 positions match Rust." + }, + { + "id": "javascript:cursors.ts:197:should allow for usage of move before/after", + "source": "javascript", + "file": "cursors.ts", + "line": 197, + "name": "should allow for usage of move before/after", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Before/after movement bytes and deleted-target resolution are compared directly with Rust." + }, + { + "id": "javascript:cursors.ts:212:should convert negative indices into a start cursor", + "source": "javascript", + "file": "cursors.ts", + "line": 212, + "name": "should convert negative indices into a start cursor", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Negative Go cursor indices clamp to the canonical start cursor and match Rust." + }, + { + "id": "javascript:cursors.ts:223:should convert indices >= string length into an end cursor", + "source": "javascript", + "file": "cursors.ts", + "line": 223, + "name": "should convert indices >= string length into an end cursor", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Indices at and beyond UTF-16 text length clamp to the canonical end cursor." + }, + { + "id": "javascript:cursors.ts:24:should be able to pass a doc to from() to make a shallow copy", + "source": "javascript", + "file": "cursors.ts", + "line": 24, + "name": "should be able to pass a doc to from() to make a shallow copy", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript Automerge.from(doc) shallow-copy binding helper; Go clones via save/load." + }, + { + "id": "javascript:cursors.ts:37:can use cursors in common text operations", + "source": "javascript", + "file": "cursors.ts", + "line": 37, + "name": "can use cursors in common text operations", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "A stable cursor is resolved and used for replacement in native and Rust texts." + }, + { + "id": "javascript:cursors.ts:5:can use cursors in splice calls", + "source": "javascript", + "file": "cursors.ts", + "line": 5, + "name": "can use cursors in splice calls", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_CursorModesMatchReference" + ], + "rationale": "Cursor-addressed UTF-16 splice operations produce identical native and Rust text." + }, + { + "id": "javascript:cursors.ts:61:should use javascript string indices", + "source": "javascript", + "file": "cursors.ts", + "line": 61, + "name": "should use javascript string indices", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_UTF16CursorBoundariesMatchReference" + ], + "rationale": "Cursor behavior at every relevant UTF-16 boundary of an emoji string matches Rust's JavaScript indexing mode." + }, + { + "id": "javascript:cursors.ts:76:patch callbacks inform where they came from", + "source": "javascript", + "file": "cursors.ts", + "line": 76, + "name": "patch callbacks inform where they came from", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript patchCallback PatchSource metadata; a binding-specific callback channel with no wire or state interop meaning." + }, + { + "id": "javascript:error.ts:5:proxy handler throws an error, not a string", + "source": "javascript", + "file": "error.ts", + "line": 5, + "name": "proxy handler throws an error, not a string", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:extra_api_tests.ts:6:should allow you to load incrementally", + "source": "javascript", + "file": "extra_api_tests.ts", + "line": 6, + "name": "should allow you to load incrementally", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Native and Rust incrementally load each other's changes, ignore duplicates, and converge heads." + }, + { + "id": "javascript:fragments_test.ts:17:returns fragment metadata with level filtering and lookup", + "source": "javascript", + "file": "fragments_test.ts", + "line": 17, + "name": "returns fragment metadata with level filtering and lookup", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:fragments_test.ts:41:exports commit and fragment inputs with matching bytes", + "source": "javascript", + "file": "fragments_test.ts", + "line": 41, + "name": "exports commit and fragment inputs with matching bytes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:fragments_test.ts:66:can reconstruct a document from fragments and commits", + "source": "javascript", + "file": "fragments_test.ts", + "line": 66, + "name": "can reconstruct a document from fragments and commits", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:fragments_test.ts:79:reports addCommits and addFragments patch sources", + "source": "javascript", + "file": "fragments_test.ts", + "line": 79, + "name": "reports addCommits and addFragments patch sources", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1010:should not allow several references to the same list object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1010, + "name": "should not allow several references to the same list object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1033:should allow deleting counters from maps", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1033, + "name": "should allow deleting counters from maps", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_CounterDeletionMatchesReference" + ], + "rationale": "Native and Rust both delete a map counter after it is committed." + }, + { + "id": "javascript:legacy_tests.ts:1070:should merge concurrent updates of different properties", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1070, + "name": "should merge concurrent updates of different properties", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1083:should add concurrent increments of the same property", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1083, + "name": "should add concurrent increments of the same property", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Concurrent increments merge additively and are loaded by the JavaScript-compatible Rust engine." + }, + { + "id": "javascript:legacy_tests.ts:1097:should add increments only to the values they precede", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1097, + "name": "should add increments only to the values they precede", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1119:should detect concurrent updates of the same field", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1119, + "name": "should detect concurrent updates of the same field", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1134:should detect concurrent updates of the same list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1134, + "name": "should detect concurrent updates of the same list element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1151:should handle assignment conflicts of different types", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1151, + "name": "should handle assignment conflicts of different types", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1164:should handle changes within a conflicting map field", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1164, + "name": "should handle changes within a conflicting map field", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1176:should handle changes within a conflicting list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1176, + "name": "should handle changes within a conflicting list element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1195:should not merge concurrently assigned nested maps", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1195, + "name": "should not merge concurrently assigned nested maps", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1210:should clear conflicts after assigning a new value", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1210, + "name": "should clear conflicts after assigning a new value", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "A two-actor conflict is resolved by assignment and GetAll returns one value." + }, + { + "id": "javascript:legacy_tests.ts:1222:should handle concurrent insertions at different list positions", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1222, + "name": "should handle concurrent insertions at different list positions", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1232:should handle concurrent insertions at the same list position", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1232, + "name": "should handle concurrent insertions at the same list position", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1247:should handle concurrent assignment and deletion of a map entry", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1247, + "name": "should handle concurrent assignment and deletion of a map entry", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1260:should handle concurrent assignment and deletion of a list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1260, + "name": "should handle concurrent assignment and deletion of a list element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1278:should handle insertion after a deleted list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1278, + "name": "should handle insertion after a deleted list element", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_InsertAfterConcurrentDeleteMatchesReference" + ], + "rationale": "Delete/insert concurrency preserves the insertion anchored to a deleted element." + }, + { + "id": "javascript:legacy_tests.ts:1293:should handle concurrent deletion of the same element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1293, + "name": "should handle concurrent deletion of the same element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1305:should handle concurrent deletion of different elements", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1305, + "name": "should handle concurrent deletion of different elements", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1317:should handle concurrent updates at different levels of the tree", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1317, + "name": "should handle concurrent updates at different levels of the tree", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1343:should handle updates of concurrently deleted objects", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1343, + "name": "should handle updates of concurrently deleted objects", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:135:should not register any conflicts on repeated assignment", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 135, + "name": "should not register any conflicts on repeated assignment", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Repeated writes from one actor retain only the final scalar value." + }, + { + "id": "javascript:legacy_tests.ts:1355:should not interleave sequence insertions at the same position", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1355, + "name": "should not interleave sequence insertions at the same position", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Concurrent insertion chunks remain contiguous with native and Rust parity." + }, + { + "id": "javascript:legacy_tests.ts:1374:should handle insertion by greater actor ID", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1374, + "name": "should handle insertion by greater actor ID", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Explicit deterministic actors verify greater-ID insertion ordering." + }, + { + "id": "javascript:legacy_tests.ts:1383:should handle insertion by lesser actor ID", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1383, + "name": "should handle insertion by lesser actor ID", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Explicit deterministic actors verify lesser-ID insertion ordering." + }, + { + "id": "javascript:legacy_tests.ts:1392:should handle insertion regardless of actor ID", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1392, + "name": "should handle insertion regardless of actor ID", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1399:should make insertion order consistent with causality", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1399, + "name": "should make insertion order consistent with causality", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1413:should save and restore an empty document", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1413, + "name": "should save and restore an empty document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1418:should generate a new random actor ID", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1418, + "name": "should generate a new random actor ID", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1432:should allow a custom actor ID to be set", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1432, + "name": "should allow a custom actor ID to be set", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1437:should reconstitute complex datatypes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1437, + "name": "should reconstitute complex datatypes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:144:should group several changes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 144, + "name": "should group several changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1448:should save and load maps with @ symbols in the keys", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1448, + "name": "should save and load maps with @ symbols in the keys", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_MapKeysMatchReference" + ], + "rationale": "Empty, lexical, and @-containing keys persist through native and Rust save/load." + }, + { + "id": "javascript:legacy_tests.ts:1457:should reconstitute conflicts", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1457, + "name": "should reconstitute conflicts", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1480:should reconstitute element ID counters", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1480, + "name": "should reconstitute element ID counters", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1547:should allow a reloaded list to be mutated", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1547, + "name": "should allow a reloaded list to be mutated", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1555:should reload a document containing deflated columns", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1555, + "name": "should reload a document containing deflated columns", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1570:should call patchCallback if supplied to load", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1570, + "name": "should call patchCallback if supplied to load", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:158:should freeze objects if desired", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 158, + "name": "should freeze objects if desired", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1596:should return an empty history for an empty document", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1596, + "name": "should return an empty history for an empty document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1600:should make past document states accessible", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1600, + "name": "should make past document states accessible", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1618:should make change messages accessible", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1618, + "name": "should make change messages accessible", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1639:should return an empty list on an empty document", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1639, + "name": "should return an empty list on an empty document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1644:should return an empty list when nothing changed", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1644, + "name": "should return an empty list when nothing changed", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1652:should do nothing when applying an empty list of changes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1652, + "name": "should do nothing when applying an empty list of changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1660:should return all changes when compared to an empty document", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1660, + "name": "should return all changes when compared to an empty document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1673:should allow a document copy to be reconstructed from scratch", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1673, + "name": "should allow a document copy to be reconstructed from scratch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1687:should return changes since the last given version", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1687, + "name": "should return changes since the last given version", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1702:should incrementally apply changes since the last given version", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1702, + "name": "should incrementally apply changes since the last given version", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "Two successive incremental batches apply in both Go-to-Rust and Rust-to-Go directions." + }, + { + "id": "javascript:legacy_tests.ts:1719:should handle updates to a list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1719, + "name": "should handle updates to a list element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1734:should handle updates to a text object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1734, + "name": "should handle updates to a text object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1764:should report missing dependencies with out-of-order applyChanges", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1764, + "name": "should report missing dependencies with out-of-order applyChanges", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "A child-first change is queued, reports only its missing parent, then clears the dependency after the parent arrives." + }, + { + "id": "javascript:legacy_tests.ts:1780:should call patchCallback if supplied when applying changes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1780, + "name": "should call patchCallback if supplied when applying changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1820:should merge multiple applied changes into one patch", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1820, + "name": "should merge multiple applied changes into one patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:1838:should call a patchCallback registered on doc initialisation", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 1838, + "name": "should call a patchCallback registered on doc initialisation", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:189:should allow repeated reading and writing of values", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 189, + "name": "should allow repeated reading and writing of values", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:201:should not record conflicts when writing the same field several times within one change", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 201, + "name": "should not record conflicts when writing the same field several times within one change", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:211:should return the unchanged state object if nothing changed", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 211, + "name": "should return the unchanged state object if nothing changed", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:216:should ignore field updates that write the existing value", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 216, + "name": "should ignore field updates that write the existing value", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:222:should not ignore field updates that resolve a conflict", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 222, + "name": "should not ignore field updates that resolve a conflict", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "The conflict-resolving map update is committed and remains the sole value." + }, + { + "id": "javascript:legacy_tests.ts:23:should initially be an empty map", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 23, + "name": "should initially be an empty map", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_MapKeysMatchReference" + ], + "rationale": "Fresh native and Rust root maps expose no keys." + }, + { + "id": "javascript:legacy_tests.ts:237:should ignore list element updates that write the existing value", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 237, + "name": "should ignore list element updates that write the existing value", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:243:should not ignore list element updates that resolve a conflict", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 243, + "name": "should not ignore list element updates that resolve a conflict", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:259:should sanity-check arguments", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 259, + "name": "should sanity-check arguments", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:271:should not allow nested change blocks", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 271, + "name": "should not allow nested change blocks", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:28:should allow instantiating from an existing object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 28, + "name": "should allow instantiating from an existing object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:288:should not allow the same base document to be used for multiple changes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 288, + "name": "should not allow the same base document to be used for multiple changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:295:should allow a document to be cloned", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 295, + "name": "should allow a document to be cloned", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Native and Rust forks preserve base history while accepting independent changes." + }, + { + "id": "javascript:legacy_tests.ts:306:should work with Object.assign merges", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 306, + "name": "should work with Object.assign merges", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:316:should support Date objects in maps", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 316, + "name": "should support Date objects in maps", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativePreservesJavaScriptDataModel" + ], + "rationale": "A JavaScript Date stored in a map round-trips through the native Go engine and is re-read identically by JavaScript." + }, + { + "id": "javascript:legacy_tests.ts:325:should support Date objects in lists", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 325, + "name": "should support Date objects in lists", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativePreservesJavaScriptDataModel" + ], + "rationale": "A JavaScript Date stored in a list round-trips through the native Go engine and is re-read identically by JavaScript." + }, + { + "id": "javascript:legacy_tests.ts:334:should call patchCallback if supplied", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 334, + "name": "should call patchCallback if supplied", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:34:should allow merging of an object initialized with `from`", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 34, + "name": "should allow merging of an object initialized with `from`", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:374:should call a patchCallback set up on document initialisation", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 374, + "name": "should call a patchCallback set up on document initialisation", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:40:should allow passing an actorId when instantiating from an existing object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 40, + "name": "should allow passing an actorId when instantiating from an existing object", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Fork assigns the requested actor before creating and merging independent changes." + }, + { + "id": "javascript:legacy_tests.ts:402:should append an empty change to the history", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 402, + "name": "should append an empty change to the history", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:413:should reference dependencies", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 413, + "name": "should reference dependencies", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:429:should handle single-property assignment", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 429, + "name": "should handle single-property assignment", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:437:should allow floating-point values", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 437, + "name": "should allow floating-point values", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:442:should handle multi-property assignment", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 442, + "name": "should handle multi-property assignment", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:451:should handle root property deletion", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 451, + "name": "should handle root property deletion", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:46:accepts an empty object as initial state", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 46, + "name": "accepts an empty object as initial state", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:464:should follow JS delete behavior", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 464, + "name": "should follow JS delete behavior", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:482:should allow the type of a property to be changed", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 482, + "name": "should allow the type of a property to be changed", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:493:should not error on empty string keys", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 493, + "name": "should not error on empty string keys", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:502:should not allow assignment of unsupported datatypes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 502, + "name": "should not allow assignment of unsupported datatypes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:51:accepts an array as initial state, but converts it to an object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 51, + "name": "accepts an array as initial state, but converts it to an object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:521:should assign an objectId to nested maps", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 521, + "name": "should assign an objectId to nested maps", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:533:should handle assignment of a nested property", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 533, + "name": "should handle assignment of a nested property", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:547:should handle assignment of an object literal", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 547, + "name": "should handle assignment of an object literal", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:559:should handle assignment of multiple nested properties", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 559, + "name": "should handle assignment of multiple nested properties", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:57:accepts strings as initial values, but treats them as an array of characters", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 57, + "name": "accepts strings as initial values, but treats them as an array of characters", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Legacy JavaScript Text-as-array-of-characters proxy semantics; superseded by string text and specific to the JS binding." + }, + { + "id": "javascript:legacy_tests.ts:574:should handle arbitrary-depth nesting", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 574, + "name": "should handle arbitrary-depth nesting", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:588:should allow an old object to be replaced with a new one", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 588, + "name": "should allow an old object to be replaced with a new one", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:615:should allow fields to be changed between primitive and nested map", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 615, + "name": "should allow fields to be changed between primitive and nested map", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:627:should not allow several references to the same map object", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 627, + "name": "should not allow several references to the same map object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:63:ignores numbers provided as initial values", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 63, + "name": "ignores numbers provided as initial values", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:647:should not allow object-copying idioms", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 647, + "name": "should not allow object-copying idioms", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:664:should handle deletion of properties within a map", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 664, + "name": "should handle deletion of properties within a map", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:676:should handle deletion of references to a map", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 676, + "name": "should handle deletion of references to a map", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:69:ignores booleans provided as initial values", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 69, + "name": "ignores booleans provided as initial values", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:690:should allow elements to be inserted", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 690, + "name": "should allow elements to be inserted", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:704:should handle assignment of a list literal", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 704, + "name": "should handle assignment of a list literal", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:718:should only allow numeric indexes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 718, + "name": "should only allow numeric indexes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:738:should handle deletion of list elements", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 738, + "name": "should handle deletion of list elements", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:753:should handle assignment of individual list indexes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 753, + "name": "should handle assignment of individual list indexes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:767:concurrent edits insert in reverse actorid order if counters equal", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 767, + "name": "concurrent edits insert in reverse actorid order if counters equal", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:778:concurrent edits insert in reverse counter order if different", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 778, + "name": "concurrent edits insert in reverse counter order if different", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:790:should treat out-by-one assignment as insertion", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 790, + "name": "should treat out-by-one assignment as insertion", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:800:should not allow out-of-range assignment", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 800, + "name": "should not allow out-of-range assignment", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:807:should allow bulk assignment of multiple list indexes", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 807, + "name": "should allow bulk assignment of multiple list indexes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:822:should handle nested objects", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 822, + "name": "should handle nested objects", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:848:should handle nested lists", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 848, + "name": "should handle nested lists", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:85:should not mutate objects", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 85, + "name": "should not mutate objects", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:870:should handle deep nesting", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 870, + "name": "should handle deep nesting", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:91:changes should be retrievable", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 91, + "name": "changes should be retrievable", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:911:should handle replacement of the entire list", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 911, + "name": "should handle replacement of the entire list", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:932:should allow assignment to change the type of a list element", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 932, + "name": "should allow assignment to change the type of a list element", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:964:should allow list creation and assignment in the same change callback", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 964, + "name": "should allow list creation and assignment in the same change callback", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:972:should allow adding and removing list elements in the same change callback", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 972, + "name": "should allow adding and removing list elements in the same change callback", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:legacy_tests.ts:994:should handle arbitrary-depth nesting", + "source": "javascript", + "file": "legacy_tests.ts", + "line": 994, + "name": "should handle arbitrary-depth nesting", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:marks.ts:107:patches properly report marks on end of expand true", + "source": "javascript", + "file": "marks.ts", + "line": 107, + "name": "patches properly report marks on end of expand true", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_ExpandMarksAreReportedInPatches" + ], + "rationale": "Mark and unmark operations are reported as Mark patches, and text spliced into an expanding mark carries it, matching the reference." + }, + { + "id": "javascript:marks.ts:6:should allow marks that can be seen in patches", + "source": "javascript", + "file": "marks.ts", + "line": 6, + "name": "should allow marks that can be seen in patches", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSMarks_MarksSeenInPatches" + ], + "rationale": "Mark and unmark operations are reported as Mark patches, and text spliced into an expanding mark carries it, matching the reference." + }, + { + "id": "javascript:marks.ts:73:should do unicode sensibly", + "source": "javascript", + "file": "marks.ts", + "line": 73, + "name": "should do unicode sensibly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeUnicodeMarks" + ], + "rationale": "An emoji-range mark followed by a UTF-16 prefix insertion materializes identically in Go and JavaScript." + }, + { + "id": "javascript:new-change-api.ts:17:should be able to insert into a list", + "source": "javascript", + "file": "new-change-api.ts", + "line": 17, + "name": "should be able to insert into a list", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:new-change-api.ts:25:should be able to delete from a list", + "source": "javascript", + "file": "new-change-api.ts", + "line": 25, + "name": "should be able to delete from a list", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:new-change-api.ts:5:should be able to make simple changes to a document", + "source": "javascript", + "file": "new-change-api.ts", + "line": 5, + "name": "should be able to make simple changes to a document", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:next_test.ts:5:should expose a next export to maintain backwards compatiblity with 2.0", + "source": "javascript", + "file": "next_test.ts", + "line": 5, + "name": "should expose a next export to maintain backwards compatiblity with 2.0", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:next_test.ts:9:should have the same types as the main export", + "source": "javascript", + "file": "next_test.ts", + "line": 9, + "name": "should have the same types as the main export", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:patches.ts:120:should allow diffing a sub-object", + "source": "javascript", + "file": "patches.ts", + "line": 120, + "name": "should allow diffing a sub-object", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:201:should correctly diff the reverse of deleting a string value on next", + "source": "javascript", + "file": "patches.ts", + "line": 201, + "name": "should correctly diff the reverse of deleting a string value on next", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:225:should produce correct patches during changeAt", + "source": "javascript", + "file": "patches.ts", + "line": 225, + "name": "should produce correct patches during changeAt", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:254:should apply a map update", + "source": "javascript", + "file": "patches.ts", + "line": 254, + "name": "should apply a map update", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:267:should apply a list update patch", + "source": "javascript", + "file": "patches.ts", + "line": 267, + "name": "should apply a list update patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:27:should provide correct before and after states when an array has a value deleted", + "source": "javascript", + "file": "patches.ts", + "line": 27, + "name": "should provide correct before and after states when an array has a value deleted", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:278:should apply a list insertion patch", + "source": "javascript", + "file": "patches.ts", + "line": 278, + "name": "should apply a list insertion patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:289:should apply a list deletion patch without length", + "source": "javascript", + "file": "patches.ts", + "line": 289, + "name": "should apply a list deletion patch without length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:301:should apply a list deletion patch with length", + "source": "javascript", + "file": "patches.ts", + "line": 301, + "name": "should apply a list deletion patch with length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:314:should apply a text splice patch", + "source": "javascript", + "file": "patches.ts", + "line": 314, + "name": "should apply a text splice patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:325:should apply a text deletion patch without length", + "source": "javascript", + "file": "patches.ts", + "line": 325, + "name": "should apply a text deletion patch without length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:335:should apply a text deletion patch with length", + "source": "javascript", + "file": "patches.ts", + "line": 335, + "name": "should apply a text deletion patch with length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:346:should apply an increment patch", + "source": "javascript", + "file": "patches.ts", + "line": 346, + "name": "should apply an increment patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:359:should apply a mark patch", + "source": "javascript", + "file": "patches.ts", + "line": 359, + "name": "should apply a mark patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:380:should apply an unmark patch", + "source": "javascript", + "file": "patches.ts", + "line": 380, + "name": "should apply an unmark patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:405:should apply a map update to a nested map", + "source": "javascript", + "file": "patches.ts", + "line": 405, + "name": "should apply a map update to a nested map", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:416:should apply a list update patch", + "source": "javascript", + "file": "patches.ts", + "line": 416, + "name": "should apply a list update patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:427:should apply a list insertion patch", + "source": "javascript", + "file": "patches.ts", + "line": 427, + "name": "should apply a list insertion patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:438:should apply a list deletion patch without length", + "source": "javascript", + "file": "patches.ts", + "line": 438, + "name": "should apply a list deletion patch without length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:450:should apply a list deletion patch with length", + "source": "javascript", + "file": "patches.ts", + "line": 450, + "name": "should apply a list deletion patch with length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:463:should apply a text splice patch", + "source": "javascript", + "file": "patches.ts", + "line": 463, + "name": "should apply a text splice patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:474:should apply a text deletion patch without length", + "source": "javascript", + "file": "patches.ts", + "line": 474, + "name": "should apply a text deletion patch without length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:484:should apply a text deletion patch with length", + "source": "javascript", + "file": "patches.ts", + "line": 484, + "name": "should apply a text deletion patch with length", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:49:should provide correct before and after states when an object property has been removed", + "source": "javascript", + "file": "patches.ts", + "line": 49, + "name": "should provide correct before and after states when an object property has been removed", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:495:should apply an increment patch", + "source": "javascript", + "file": "patches.ts", + "line": 495, + "name": "should apply an increment patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:506:should ignore a mark patch", + "source": "javascript", + "file": "patches.ts", + "line": 506, + "name": "should ignore a mark patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:523:should ignore an unmark patch", + "source": "javascript", + "file": "patches.ts", + "line": 523, + "name": "should ignore an unmark patch", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:535:should apply a map update to a map in a list in a map in a list", + "source": "javascript", + "file": "patches.ts", + "line": 535, + "name": "should apply a map update to a map in a list in a map in a list", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:7:should provide access to before and after states", + "source": "javascript", + "file": "patches.ts", + "line": 7, + "name": "should provide access to before and after states", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:76:should return a set of patches", + "source": "javascript", + "file": "patches.ts", + "line": 76, + "name": "should return a set of patches", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:patches.ts:96:should throw a nice exception if before or after are not an array", + "source": "javascript", + "file": "patches.ts", + "line": 96, + "name": "should throw a nice exception if before or after are not an array", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:proxies.ts:104:should be able to insert new values", + "source": "javascript", + "file": "proxies.ts", + "line": 104, + "name": "should be able to insert new values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:113:should work with only a start parameter", + "source": "javascript", + "file": "proxies.ts", + "line": 113, + "name": "should work with only a start parameter", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:122:should throw a useful RangeError when attempting to splice undefined values", + "source": "javascript", + "file": "proxies.ts", + "line": 122, + "name": "should throw a useful RangeError when attempting to splice undefined values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:133:does allow null values", + "source": "javascript", + "file": "proxies.ts", + "line": 133, + "name": "does allow null values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:141:does not allow undefined values", + "source": "javascript", + "file": "proxies.ts", + "line": 141, + "name": "does not allow undefined values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:150:should print the property path in the error when setting an undefined key", + "source": "javascript", + "file": "proxies.ts", + "line": 150, + "name": "should print the property path in the error when setting an undefined key", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:16:should throw a useful RangeError when attempting to set a document inside itself", + "source": "javascript", + "file": "proxies.ts", + "line": 16, + "name": "should throw a useful RangeError when attempting to set a document inside itself", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:161:should print the property path in the error when setting an undefined key", + "source": "javascript", + "file": "proxies.ts", + "line": 161, + "name": "should print the property path in the error when setting an undefined key", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:170:should support .at() to access values", + "source": "javascript", + "file": "proxies.ts", + "line": 170, + "name": "should support .at() to access values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:181:should support objects cloned with structuredClone", + "source": "javascript", + "file": "proxies.ts", + "line": 181, + "name": "should support objects cloned with structuredClone", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:28:should return iterable entries", + "source": "javascript", + "file": "proxies.ts", + "line": 28, + "name": "should return iterable entries", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:41:should return iterable values", + "source": "javascript", + "file": "proxies.ts", + "line": 41, + "name": "should return iterable values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:53:should return iterable keys", + "source": "javascript", + "file": "proxies.ts", + "line": 53, + "name": "should return iterable keys", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:72:should return the index of a value for a string in a list of strings", + "source": "javascript", + "file": "proxies.ts", + "line": 72, + "name": "should return the index of a value for a string in a list of strings", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:78:should return -1 if the value is not found", + "source": "javascript", + "file": "proxies.ts", + "line": 78, + "name": "should return -1 if the value is not found", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:86:should be able to remove a defined number of list entries", + "source": "javascript", + "file": "proxies.ts", + "line": 86, + "name": "should be able to remove a defined number of list entries", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:proxies.ts:95:should be able to remove a defined number of list entries and add new ones", + "source": "javascript", + "file": "proxies.ts", + "line": 95, + "name": "should be able to remove a defined number of list entries and add new ones", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises JavaScript packaging or Proxy object semantics that do not exist in the Go API." + }, + { + "id": "javascript:sync_test.ts:1022:should allow a subset of changes to be sent", + "source": "javascript", + "file": "sync_test.ts", + "line": 1022, + "name": "should allow a subset of changes to be sent", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestBackendSync_SendsOnlyChangesSinceRemoteHeads" + ], + "rationale": "After acknowledging the initial frontier, only the subsequent change is sent." + }, + { + "id": "javascript:sync_test.ts:1096:should report whether the other end has our changes", + "source": "javascript", + "file": "sync_test.ts", + "line": 1096, + "name": "should report whether the other end has our changes", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:1139:should not apply incoming changes when read-only", + "source": "javascript", + "file": "sync_test.ts", + "line": 1139, + "name": "should not apply incoming changes when read-only", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Read-only behavior is tested in both native-to-Rust and Rust-to-native directions." + }, + { + "id": "javascript:sync_test.ts:1155:should discover peer read-only status", + "source": "javascript", + "file": "sync_test.ts", + "line": 1155, + "name": "should discover peer read-only status", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Both implementations expose the remote read-only state after flags are exchanged." + }, + { + "id": "javascript:sync_test.ts:1172:should allow switching from read-only to read-write", + "source": "javascript", + "file": "sync_test.ts", + "line": 1172, + "name": "should allow switching from read-only to read-write", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ReadOnlyParity", + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "One-sided and simultaneous read-only transitions reset and converge against Rust." + }, + { + "id": "javascript:sync_test.ts:127:n1 should offer all changes to n2 when starting from nothing", + "source": "javascript", + "file": "sync_test.ts", + "line": 127, + "name": "n1 should offer all changes to n2 when starting from nothing", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:141:should sync peers where one has commits the other does not", + "source": "javascript", + "file": "sync_test.ts", + "line": 141, + "name": "should sync peers where one has commits the other does not", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:155:should work with prior sync state", + "source": "javascript", + "file": "sync_test.ts", + "line": 155, + "name": "should work with prior sync state", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ResumesPersistedSession", + "TestSyncState_ResendsInFlightMessageAfterRestore" + ], + "rationale": "Quiescent and in-flight serialized sync sessions resume and converge." + }, + { + "id": "javascript:sync_test.ts:175:should not generate messages once synced", + "source": "javascript", + "file": "sync_test.ts", + "line": 175, + "name": "should not generate messages once synced", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ExchangesConcurrentChanges", + "TestPureGoDocument_SyncWaitsForPeerResponse" + ], + "rationale": "The shared sync helper requires bounded quiescence and generation blocks while awaiting acknowledgement." + }, + { + "id": "javascript:sync_test.ts:219:should allow simultaneous messages during synchronization", + "source": "javascript", + "file": "sync_test.ts", + "line": 219, + "name": "should allow simultaneous messages during synchronization", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestPureGoDocument_ReferenceEditsWhileMessageInFlight" + ], + "rationale": "Native and official peers exchange edits created while another message is in flight." + }, + { + "id": "javascript:sync_test.ts:299:should assume sent changes were recieved until we hear otherwise", + "source": "javascript", + "file": "sync_test.ts", + "line": 299, + "name": "should assume sent changes were recieved until we hear otherwise", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:327:should work regardless of who initiates the exchange", + "source": "javascript", + "file": "sync_test.ts", + "line": 327, + "name": "should work regardless of who initiates the exchange", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:350:should work without prior sync state", + "source": "javascript", + "file": "sync_test.ts", + "line": 350, + "name": "should work without prior sync state", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:374:should work with prior sync state", + "source": "javascript", + "file": "sync_test.ts", + "line": 374, + "name": "should work with prior sync state", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ResumesPersistedSession", + "TestSyncState_ResendsInFlightMessageAfterRestore" + ], + "rationale": "Quiescent and in-flight serialized sync sessions resume and converge." + }, + { + "id": "javascript:sync_test.ts:403:should ensure non-empty state after sync", + "source": "javascript", + "file": "sync_test.ts", + "line": 403, + "name": "should ensure non-empty state after sync", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:417:should re-sync after one node crashed with data loss", + "source": "javascript", + "file": "sync_test.ts", + "line": 417, + "name": "should re-sync after one node crashed with data loss", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:459:should resync after one node experiences data loss without disconnecting", + "source": "javascript", + "file": "sync_test.ts", + "line": 459, + "name": "should resync after one node experiences data loss without disconnecting", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:482:should handle changes concurrent to the last sync heads", + "source": "javascript", + "file": "sync_test.ts", + "line": 482, + "name": "should handle changes concurrent to the last sync heads", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ExchangesConcurrentChanges", + "TestPureGoDocument_RandomConcurrentSyncParity" + ], + "rationale": "Both peers edit from the last shared frontier before each randomized sync round." + }, + { + "id": "javascript:sync_test.ts:518:should handle histories with lots of branching and merging", + "source": "javascript", + "file": "sync_test.ts", + "line": 518, + "name": "should handle histories with lots of branching and merging", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:54:should send a sync message implying no local data", + "source": "javascript", + "file": "sync_test.ts", + "line": 54, + "name": "should send a sync message implying no local data", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:565:should handle a false-positive head", + "source": "javascript", + "file": "sync_test.ts", + "line": 565, + "name": "should handle a false-positive head", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:657:should sync two nodes without connection reset", + "source": "javascript", + "file": "sync_test.ts", + "line": 657, + "name": "should sync two nodes without connection reset", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:664:should sync two nodes with connection reset", + "source": "javascript", + "file": "sync_test.ts", + "line": 664, + "name": "should sync two nodes with connection reset", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:672:should sync three nodes", + "source": "javascript", + "file": "sync_test.ts", + "line": 672, + "name": "should sync three nodes", + "classification": "covered", + "requirement": "api-convenience", + "localTests": [ + "TestSyncState_ThreePeerRelayConvergesWithReference" + ], + "rationale": "Three native/reference peers relay and converge concurrent changes." + }, + { + "id": "javascript:sync_test.ts:68:should not reply after the first round if we have no data as well", + "source": "javascript", + "file": "sync_test.ts", + "line": 68, + "name": "should not reply after the first round if we have no data as well", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:701:should not require an additional request when a false-positive depends on a true-negative", + "source": "javascript", + "file": "sync_test.ts", + "line": 701, + "name": "should not require an additional request when a false-positive depends on a true-negative", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:769:should handle chains of false-positives", + "source": "javascript", + "file": "sync_test.ts", + "line": 769, + "name": "should handle chains of false-positives", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:818:should allow the false-positive hash to be explicitly requested", + "source": "javascript", + "file": "sync_test.ts", + "line": 818, + "name": "should allow the false-positive hash to be explicitly requested", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:882:should allow multiple Bloom filters", + "source": "javascript", + "file": "sync_test.ts", + "line": 882, + "name": "should allow multiple Bloom filters", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:91:repos with equal heads do not need a reply message", + "source": "javascript", + "file": "sync_test.ts", + "line": 91, + "name": "repos with equal heads do not need a reply message", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:956:should allow any change to be requested", + "source": "javascript", + "file": "sync_test.ts", + "line": 956, + "name": "should allow any change to be requested", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:sync_test.ts:985:should ignore requests for a nonexistent change", + "source": "javascript", + "file": "sync_test.ts", + "line": 985, + "name": "should ignore requests for a nonexistent change", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "" + }, + { + "id": "javascript:text_test.ts:105:should encode the initial value as a change", + "source": "javascript", + "file": "text_test.ts", + "line": 105, + "name": "should encode the initial value as a change", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSText_InitializeTextInFrom" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "javascript:text_test.ts:115:should support unicode when creating text", + "source": "javascript", + "file": "text_test.ts", + "line": 115, + "name": "should support unicode when creating text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_JavaScriptLoadsGoDocument", + "TestConformance_GoLoadsJavaScriptDocument" + ], + "rationale": "Emoji-bearing text documents load in both JavaScript-to-Go and Go-to-JavaScript directions." + }, + { + "id": "javascript:text_test.ts:122:should allow splicing into text in arrays", + "source": "javascript", + "file": "text_test.ts", + "line": 122, + "name": "should allow splicing into text in arrays", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSText_SplicingIntoArrays" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "javascript:text_test.ts:132:should calculate a diff when updating text", + "source": "javascript", + "file": "text_test.ts", + "line": 132, + "name": "should calculate a diff when updating text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_SimpleUpdateText" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "javascript:text_test.ts:148:should handle multi character grapheme clusters", + "source": "javascript", + "file": "text_test.ts", + "line": 148, + "name": "should handle multi character grapheme clusters", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_RandomTextParity", + "TestPureGoDocument_UTF16CursorBoundariesMatchReference" + ], + "rationale": "Random UTF-16 operations include non-ASCII and surrogate pairs with Rust parity." + }, + { + "id": "javascript:text_test.ts:17:should support insertion", + "source": "javascript", + "file": "text_test.ts", + "line": 17, + "name": "should support insertion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random insertion positions are checked against the official reference after every operation." + }, + { + "id": "javascript:text_test.ts:25:should support deletion", + "source": "javascript", + "file": "text_test.ts", + "line": 25, + "name": "should support deletion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random UTF-16 deletions are checked against the official reference after every operation." + }, + { + "id": "javascript:text_test.ts:36:should support implicit and explicit deletion", + "source": "javascript", + "file": "text_test.ts", + "line": 36, + "name": "should support implicit and explicit deletion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSText_ImplicitAndExplicitDeletion" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "javascript:text_test.ts:48:should handle concurrent insertion", + "source": "javascript", + "file": "text_test.ts", + "line": 48, + "name": "should handle concurrent insertion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_RandomConcurrentSyncParity" + ], + "rationale": "Both engines insert concurrently at randomized positions and compare values and heads." + }, + { + "id": "javascript:text_test.ts:60:should handle text and other ops in the same change", + "source": "javascript", + "file": "text_test.ts", + "line": 60, + "name": "should handle text and other ops in the same change", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSText_TextAndOtherOpsSameChange" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "javascript:text_test.ts:70:should serialize to JSON as a simple string", + "source": "javascript", + "file": "text_test.ts", + "line": 70, + "name": "should serialize to JSON as a simple string", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript JSON.stringify serialization shape of the document proxy; no wire or state interop meaning." + }, + { + "id": "javascript:text_test.ts:77:should allow modification after an object is assigned to a document", + "source": "javascript", + "file": "text_test.ts", + "line": 77, + "name": "should allow modification after an object is assigned to a document", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript mutable-proxy assignment semantics inside a change callback; Go mutates through explicit typed methods." + }, + { + "id": "javascript:text_test.ts:87:should not allow modification outside of a change callback", + "source": "javascript", + "file": "text_test.ts", + "line": 87, + "name": "should not allow modification outside of a change callback", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "JavaScript change-callback guard on the mutable proxy; Go has no ambient-mutation API to guard." + }, + { + "id": "javascript:text_test.ts:95:should initialize text in Automerge.from()", + "source": "javascript", + "file": "text_test.ts", + "line": 95, + "name": "should initialize text in Automerge.from()", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestJSText_InitializeTextInFrom" + ], + "rationale": "Cross-engine text behavior reproduced against the reference." + }, + { + "id": "rust-doc:automerge/src/autocommit.rs:223:automerge/src/autocommit.rs - autocommit::AutoCommit::diff (line 223)", + "source": "rust-doc", + "file": "automerge/src/autocommit.rs", + "line": 223, + "name": "automerge/src/autocommit.rs - autocommit::AutoCommit::diff (line 223)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/autocommit.rs:319:automerge/src/autocommit.rs - autocommit::AutoCommit::diff_incremental (line 319)", + "source": "rust-doc", + "file": "automerge/src/autocommit.rs", + "line": 319, + "name": "automerge/src/autocommit.rs - autocommit::AutoCommit::diff_incremental (line 319)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/autocommit.rs:639:automerge/src/autocommit.rs - autocommit::AutoCommit::commit_with (line 639)", + "source": "rust-doc", + "file": "automerge/src/autocommit.rs", + "line": 639, + "name": "automerge/src/autocommit.rs - autocommit::AutoCommit::commit_with (line 639)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/autoserde.rs:9:automerge/src/autoserde.rs - autoserde::AutoSerde (line 9)", + "source": "rust-doc", + "file": "automerge/src/autoserde.rs", + "line": 9, + "name": "automerge/src/autoserde.rs - autoserde::AutoSerde (line 9)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/patches/patch_log.rs:29:automerge/src/patches/patch_log.rs - patches::patch_log::PatchLog (line 29)", + "source": "rust-doc", + "file": "automerge/src/patches/patch_log.rs", + "line": 29, + "name": "automerge/src/patches/patch_log.rs - patches::patch_log::PatchLog (line 29)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/storage/document.rs:54:automerge/src/storage/document.rs - storage::document::Document<'a>::parse (line 54)", + "source": "rust-doc", + "file": "automerge/src/storage/document.rs", + "line": 54, + "name": "automerge/src/storage/document.rs - storage::document::Document<'a>::parse (line 54)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:17:automerge/src/storage/parse.rs - storage::parse (line 17)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 17, + "name": "automerge/src/storage/parse.rs - storage::parse (line 17)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:264:automerge/src/storage/parse.rs - storage::parse::Input<'a>::split (line 264)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 264, + "name": "automerge/src/storage/parse.rs - storage::parse::Input<'a>::split (line 264)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:325:automerge/src/storage/parse.rs - storage::parse::Split::remaining (line 325)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 325, + "name": "automerge/src/storage/parse.rs - storage::parse::Split::remaining (line 325)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:56:automerge/src/storage/parse.rs - storage::parse (line 56)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 56, + "name": "automerge/src/storage/parse.rs - storage::parse (line 56)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:561:automerge/src/storage/parse.rs - storage::parse::range_of (line 561)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 561, + "name": "automerge/src/storage/parse.rs - storage::parse::range_of (line 561)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/storage/parse.rs:69:automerge/src/storage/parse.rs - storage::parse (line 69)", + "source": "rust-doc", + "file": "automerge/src/storage/parse.rs", + "line": 69, + "name": "automerge/src/storage/parse.rs - storage::parse (line 69)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/sync.rs:25:automerge/src/sync.rs - sync (line 25)", + "source": "rust-doc", + "file": "automerge/src/sync.rs", + "line": 25, + "name": "automerge/src/sync.rs - sync (line 25)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust-internal storage/sync module documentation example (rustdoc doctest) for the low-level parser and sync APIs; exercises Rust library ergonomics, not cross-engine wire or state behavior." + }, + { + "id": "rust-doc:automerge/src/transaction/manual_transaction.rs:77:automerge/src/transaction/manual_transaction.rs - transaction::manual_transaction::Transaction<'_>::commit_with (line 77)", + "source": "rust-doc", + "file": "automerge/src/transaction/manual_transaction.rs", + "line": 77, + "name": "automerge/src/transaction/manual_transaction.rs - transaction::manual_transaction::Transaction<'_>::commit_with (line 77)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:unknown:540:automerge/src/lib.rs - (line 117)", + "source": "rust-doc", + "file": "unknown", + "line": 540, + "name": "automerge/src/lib.rs - (line 117)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust-doc:unknown:541:automerge/src/lib.rs - (line 147)", + "source": "rust-doc", + "file": "unknown", + "line": 541, + "name": "automerge/src/lib.rs - (line 147)", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust rustdoc usage example for an internal/public Rust API; the underlying behavior (diff, commit options, patch log, hydration, document parse) is covered by the differential suites. No cross-engine wire or state assertion." + }, + { + "id": "rust:add_concurrent_increments_of_same_property", + "source": "rust", + "file": "tests/test.rs", + "line": 201, + "name": "add_concurrent_increments_of_same_property", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Two actors increment the same counter concurrently; Go merges to the summed value and Rust verifies it.", + "runtimeName": "add_concurrent_increments_of_same_property" + }, + { + "id": "rust:add_increments_only_to_preceeded_values", + "source": "rust", + "file": "tests/test.rs", + "line": 221, + "name": "add_increments_only_to_preceeded_values", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_AddIncrementsOnlyToPreceededValues" + ], + "rationale": "Two actors create and increment separate counters at the same key; the increments stay attached to their own counters, yielding conflicting values 1 and 3 on both engines.", + "runtimeName": "add_increments_only_to_preceeded_values" + }, + { + "id": "rust:adjacent_marks_merge", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 377, + "name": "adjacent_marks_merge", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/adjacent_marks_merge" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "adjacent_marks_merge" + }, + { + "id": "rust:adjacent_marks_stay_separate", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 417, + "name": "adjacent_marks_stay_separate", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/adjacent_marks_stay_separate" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "adjacent_marks_stay_separate" + }, + { + "id": "rust:allows_empty_keys_in_mappings", + "source": "rust", + "file": "tests/test.rs", + "line": 2328, + "name": "allows_empty_keys_in_mappings", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "The randomized map key set explicitly includes the empty string for writes, reads, overwrites, and deletion.", + "runtimeName": "allows_empty_keys_in_mappings" + }, + { + "id": "rust:alternating_mark_changes", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1164, + "name": "alternating_mark_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks_Alternating" + ], + "rationale": "Repeatedly adding and removing a mark converges on the final span set.", + "runtimeName": "alternating_mark_changes" + }, + { + "id": "rust:applying_changes_with_patch_log_from_another_document_returns_error_not_panic", + "source": "rust", + "file": "tests/test.rs", + "line": 36, + "name": "applying_changes_with_patch_log_from_another_document_returns_error_not_panic", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust API safety guard: using a PatchLog that belongs to another document returns an error instead of panicking. A Rust-binding safety contract with no cross-engine wire or state meaning.", + "runtimeName": "applying_changes_with_patch_log_from_another_document_returns_error_not_panic" + }, + { + "id": "rust:assignment_conflicts_of_different_types", + "source": "rust", + "file": "tests/test.rs", + "line": 296, + "name": "assignment_conflicts_of_different_types", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_AssignmentConflictsOfDifferentTypes" + ], + "rationale": "Three actors assign a string, a list, and a map to the same key; both engines pick the same winner and produce identical heads.", + "runtimeName": "assignment_conflicts_of_different_types" + }, + { + "id": "rust:autocommit::tests::test_autocommit_is_send", + "source": "rust", + "file": "src/autocommit.rs", + "line": 1296, + "name": "test_autocommit_is_send", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Rust Send auto-trait assertion for AutoCommit; a Rust concurrency property with no cross-engine meaning.", + "runtimeName": "autocommit::tests::test_autocommit_is_send" + }, + { + "id": "rust:automerge::current_state::tests::basic_test", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 201, + "name": "basic_test", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_Basic" + ], + "rationale": "Materializing a document with a scalar, nested map, list, and text produces the same ordered patch stream on the native and reference engines.", + "runtimeName": "automerge::current_state::tests::basic_test" + }, + { + "id": "rust:automerge::current_state::tests::test_concurrent_insertions_at_same_index", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 404, + "name": "test_concurrent_insertions_at_same_index", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_ConcurrentInsertions" + ], + "rationale": "Concurrent insertions at the same index materialize in the same converged order on both engines.", + "runtimeName": "automerge::current_state::tests::test_concurrent_insertions_at_same_index" + }, + { + "id": "rust:automerge::current_state::tests::test_counters", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 340, + "name": "test_counters", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_Counters" + ], + "rationale": "A counter with merged increments conflicting with a concurrent value materializes as a conflicted put of the summed counter on both engines.", + "runtimeName": "automerge::current_state::tests::test_counters" + }, + { + "id": "rust:automerge::current_state::tests::test_deleted_ops_omitted", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 260, + "name": "test_deleted_ops_omitted", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_DeletedOpsOmitted" + ], + "rationale": "Deleted scalars, map keys, and list elements are omitted from the materialization patches identically on both engines.", + "runtimeName": "automerge::current_state::tests::test_deleted_ops_omitted" + }, + { + "id": "rust:automerge::current_state::tests::test_insert_and_update", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 484, + "name": "test_insert_and_update", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_InsertAndUpdate" + ], + "rationale": "Inserting then overwriting list elements materializes the updated values identically on both engines.", + "runtimeName": "automerge::current_state::tests::test_insert_and_update" + }, + { + "id": "rust:automerge::current_state::tests::test_insert_objects", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 449, + "name": "test_insert_objects", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_InsertObjects" + ], + "rationale": "Inserting an object into a list materializes the insert and the nested object's properties identically on both engines.", + "runtimeName": "automerge::current_state::tests::test_insert_objects" + }, + { + "id": "rust:automerge::current_state::tests::test_load_changes", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 520, + "name": "test_load_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_LoadChanges" + ], + "rationale": "Loading a stored document and materializing current state yields the summed counter put, matching the reference.", + "runtimeName": "automerge::current_state::tests::test_load_changes" + }, + { + "id": "rust:automerge::current_state::tests::test_multiple_list_insertions", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 371, + "name": "test_multiple_list_insertions", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_MultipleListInsertions" + ], + "rationale": "Multiple list insertions materialize as the same grouped insert patch on both engines.", + "runtimeName": "automerge::current_state::tests::test_multiple_list_insertions" + }, + { + "id": "rust:automerge::current_state::tests::test_text_spliced", + "source": "rust", + "file": "src/automerge/current_state.rs", + "line": 312, + "name": "test_text_spliced", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustCurrentState_TextSpliced" + ], + "rationale": "Sequential text splices materialize as a single consolidated splice_text patch identically on both engines.", + "runtimeName": "automerge::current_state::tests::test_text_spliced" + }, + { + "id": "rust:bad_change_on_optree_node_boundary", + "source": "rust", + "file": "tests/test.rs", + "line": 1537, + "name": "bad_change_on_optree_node_boundary", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_BadChangeOnOptreeNodeBoundary" + ], + "rationale": "A document grown across an op-tree node boundary is saved, reloaded elsewhere, and a further change is transferred and reloaded with matching state and heads on both engines.", + "runtimeName": "bad_change_on_optree_node_boundary" + }, + { + "id": "rust:batch_init_map_equivalent_to_individual_ops", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 792, + "name": "batch_init_map_equivalent_to_individual_ops", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_init_map_equivalent_to_individual_ops" + }, + { + "id": "rust:batch_init_map_flat", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 713, + "name": "batch_init_map_flat", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_init_map_flat" + }, + { + "id": "rust:batch_init_map_generates_patches", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 817, + "name": "batch_init_map_generates_patches", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatchInit_MapGeneratesPatches" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference.", + "runtimeName": "batch_init_map_generates_patches" + }, + { + "id": "rust:batch_init_map_nested", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 729, + "name": "batch_init_map_nested", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_init_map_nested" + }, + { + "id": "rust:batch_init_map_survives_save_load", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 772, + "name": "batch_init_map_survives_save_load", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_init_map_survives_save_load" + }, + { + "id": "rust:batch_init_map_with_text", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 759, + "name": "batch_init_map_with_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_init_map_with_text" + }, + { + "id": "rust:batch_insert_deeply_nested", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 248, + "name": "batch_insert_deeply_nested", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_deeply_nested" + }, + { + "id": "rust:batch_insert_empty_list", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 514, + "name": "batch_insert_empty_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_empty_list" + }, + { + "id": "rust:batch_insert_empty_map", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 499, + "name": "batch_insert_empty_map", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_empty_map" + }, + { + "id": "rust:batch_insert_empty_text", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 529, + "name": "batch_insert_empty_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_empty_text" + }, + { + "id": "rust:batch_insert_equivalent_to_individual_ops", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 587, + "name": "batch_insert_equivalent_to_individual_ops", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_equivalent_to_individual_ops" + }, + { + "id": "rust:batch_insert_flat_list", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 88, + "name": "batch_insert_flat_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_flat_list" + }, + { + "id": "rust:batch_insert_flat_map", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 16, + "name": "batch_insert_flat_map", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_flat_map" + }, + { + "id": "rust:batch_insert_generates_patches", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 617, + "name": "batch_insert_generates_patches", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatchInsert_GeneratesPatches" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference.", + "runtimeName": "batch_insert_generates_patches" + }, + { + "id": "rust:batch_insert_into_existing_list", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 417, + "name": "batch_insert_into_existing_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_into_existing_list" + }, + { + "id": "rust:batch_insert_into_existing_map", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 399, + "name": "batch_insert_into_existing_map", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatch_InsertIntoExistingMap" + ], + "rationale": "Batch-creating a nested map inside a populated map preserves the existing key and materializes the new nested values.", + "runtimeName": "batch_insert_into_existing_map" + }, + { + "id": "rust:batch_insert_into_list_at_end", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 138, + "name": "batch_insert_into_list_at_end", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_into_list_at_end" + }, + { + "id": "rust:batch_insert_into_list_at_middle", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 166, + "name": "batch_insert_into_list_at_middle", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_into_list_at_middle" + }, + { + "id": "rust:batch_insert_list_of_lists", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 660, + "name": "batch_insert_list_of_lists", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_list_of_lists" + }, + { + "id": "rust:batch_insert_list_with_nested_objects", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 102, + "name": "batch_insert_list_with_nested_objects", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_list_with_nested_objects" + }, + { + "id": "rust:batch_insert_map_overwrites_existing_key", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 70, + "name": "batch_insert_map_overwrites_existing_key", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_map_overwrites_existing_key" + }, + { + "id": "rust:batch_insert_matches_hydrate_output", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 439, + "name": "batch_insert_matches_hydrate_output", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_matches_hydrate_output" + }, + { + "id": "rust:batch_insert_merges_correctly", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 353, + "name": "batch_insert_merges_correctly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatch_MergesCorrectly" + ], + "rationale": "Two forks each batch-create a distinct nested map; after merge both objects and their fields are present, verified against the reference.", + "runtimeName": "batch_insert_merges_correctly" + }, + { + "id": "rust:batch_insert_mixed_nesting", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 273, + "name": "batch_insert_mixed_nesting", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_mixed_nesting" + }, + { + "id": "rust:batch_insert_nested_maps", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 40, + "name": "batch_insert_nested_maps", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_nested_maps" + }, + { + "id": "rust:batch_insert_scalar_fails", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 129, + "name": "batch_insert_scalar_fails", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust batch_create_object rejects a scalar target; the Go hydrate API intentionally accepts scalars via PutValue. API-shape contract, not cross-engine interop.", + "runtimeName": "batch_insert_scalar_fails" + }, + { + "id": "rust:batch_insert_survives_save_load", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 323, + "name": "batch_insert_survives_save_load", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_survives_save_load" + }, + { + "id": "rust:batch_insert_text_generates_splice_patch", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 638, + "name": "batch_insert_text_generates_splice_patch", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatchInsert_TextGeneratesSplicePatch" + ], + "rationale": "A hydrated batch insertion emits the expected patch stream, matching the reference.", + "runtimeName": "batch_insert_text_generates_splice_patch" + }, + { + "id": "rust:batch_insert_text_in_list", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 233, + "name": "batch_insert_text_in_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_text_in_list" + }, + { + "id": "rust:batch_insert_transaction_rollback", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 486, + "name": "batch_insert_transaction_rollback", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_transaction_rollback" + }, + { + "id": "rust:batch_insert_various_scalar_types", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 544, + "name": "batch_insert_various_scalar_types", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_various_scalar_types" + }, + { + "id": "rust:batch_insert_with_text", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 218, + "name": "batch_insert_with_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_insert_with_text" + }, + { + "id": "rust:batch_insert_with_transaction", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 466, + "name": "batch_insert_with_transaction", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust explicit-transaction integration for batch insert; the batch result is covered elsewhere. Rust transaction-object API shape.", + "runtimeName": "batch_insert_with_transaction" + }, + { + "id": "rust:batch_put_overwrite_with_nested_structure", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 682, + "name": "batch_put_overwrite_with_nested_structure", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatch_PutOverwriteWithNestedStructure" + ], + "rationale": "Overwriting a list element with a deeply nested map and child list keeps the sibling element and materializes the nested structure.", + "runtimeName": "batch_put_overwrite_with_nested_structure" + }, + { + "id": "rust:batch_put_overwrites_existing_list_element", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 187, + "name": "batch_put_overwrites_existing_list_element", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateMatchesReference", + "TestDocument_HydrateRollback" + ], + "rationale": "Recursive map/list/text/scalar hydration, save/load, empty values, deep nesting, and rollback execute against native and Rust engines.", + "runtimeName": "batch_put_overwrites_existing_list_element" + }, + { + "id": "rust:big_list", + "source": "rust", + "file": "tests/test.rs", + "line": 1703, + "name": "big_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_BigList" + ], + "rationale": "A list of many null elements is replaced with map objects; both engines materialize N+1 maps that survive a cross-engine save/load with identical heads.", + "runtimeName": "big_list" + }, + { + "id": "rust:block_properties_change_with_marks", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1091, + "name": "block_properties_change_with_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/block_properties_change_with_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "block_properties_change_with_marks" + }, + { + "id": "rust:block_with_marked_content", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1250, + "name": "block_with_marked_content", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/block_with_marked_content" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "block_with_marked_content" + }, + { + "id": "rust:can_isolate", + "source": "rust", + "file": "tests/test.rs", + "line": 1814, + "name": "can_isolate", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTest_CanIsolate" + ], + "rationale": "Isolating to a historical frontier pins reads and branches writes with derived concurrency actors, merges during isolation stay hidden until integrate, and repeated isolate/integrate cycles reproduce the reference text and values exactly on both engines.", + "runtimeName": "can_isolate" + }, + { + "id": "rust:can_transaction_at", + "source": "rust", + "file": "tests/test.rs", + "line": 1772, + "name": "can_transaction_at", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTest_CanTransactionAt" + ], + "rationale": "Writing at a pinned historical frontier (via isolate/integrate, the AutoCommit equivalent of transaction_at) branches from those heads and merges with concurrent writes, reproducing the reference text and values on both engines.", + "runtimeName": "can_transaction_at" + }, + { + "id": "rust:change_graph::tests::clock_by_heads", + "source": "rust", + "file": "src/change_graph.rs", + "line": 924, + "name": "clock_by_heads", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "change_graph::tests::clock_by_heads" + }, + { + "id": "rust:change_graph::tests::remove_ancestors", + "source": "rust", + "file": "src/change_graph.rs", + "line": 946, + "name": "remove_ancestors", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "change_graph::tests::remove_ancestors" + }, + { + "id": "rust:changes_within_conflicting_list_element", + "source": "rust", + "file": "tests/test.rs", + "line": 347, + "name": "changes_within_conflicting_list_element", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ChangesWithinConflictingListElement" + ], + "rationale": "Two actors replace the same list element with maps and mutate them across merges; both engines expose the same winning map contents and heads.", + "runtimeName": "changes_within_conflicting_list_element" + }, + { + "id": "rust:changes_within_conflicting_map_field", + "source": "rust", + "file": "tests/test.rs", + "line": 321, + "name": "changes_within_conflicting_map_field", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ChangesWithinConflictingMapField" + ], + "rationale": "A string and a populated map conflict at one key; both engines expose the winning map with its inner value and agree on heads.", + "runtimeName": "changes_within_conflicting_map_field" + }, + { + "id": "rust:clock::tests::comparison", + "source": "rust", + "file": "src/clock.rs", + "line": 178, + "name": "comparison", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "clock::tests::comparison" + }, + { + "id": "rust:clock::tests::covers", + "source": "rust", + "file": "src/clock.rs", + "line": 159, + "name": "covers", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "clock::tests::covers" + }, + { + "id": "rust:columnar::column_range::delta::tests::bugbug", + "source": "rust", + "file": "src/columnar/column_range/delta.rs", + "line": 139, + "name": "bugbug", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::delta::tests::bugbug" + }, + { + "id": "rust:columnar::column_range::delta::tests::encode_decode_delta", + "source": "rust", + "file": "src/columnar/column_range/delta.rs", + "line": 115, + "name": "encode_decode_delta", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::delta::tests::encode_decode_delta" + }, + { + "id": "rust:columnar::column_range::delta::tests::splice_delta", + "source": "rust", + "file": "src/columnar/column_range/delta.rs", + "line": 128, + "name": "splice_delta", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::delta::tests::splice_delta" + }, + { + "id": "rust:columnar::column_range::opid_list::tests::encode_decode_opid_list", + "source": "rust", + "file": "src/columnar/column_range/opid_list.rs", + "line": 308, + "name": "encode_decode_opid_list", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::opid_list::tests::encode_decode_opid_list" + }, + { + "id": "rust:columnar::column_range::opid_list::tests::splice_opid_list", + "source": "rust", + "file": "src/columnar/column_range/opid_list.rs", + "line": 315, + "name": "splice_opid_list", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::opid_list::tests::splice_opid_list" + }, + { + "id": "rust:columnar::column_range::rle::tests::rle_int_insert", + "source": "rust", + "file": "src/columnar/column_range/rle.rs", + "line": 163, + "name": "rle_int_insert", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::rle::tests::rle_int_insert" + }, + { + "id": "rust:columnar::column_range::rle::tests::rle_int_round_trip", + "source": "rust", + "file": "src/columnar/column_range/rle.rs", + "line": 145, + "name": "rle_int_round_trip", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::rle::tests::rle_int_round_trip" + }, + { + "id": "rust:columnar::column_range::rle::tests::splice_ints", + "source": "rust", + "file": "src/columnar/column_range/rle.rs", + "line": 197, + "name": "splice_ints", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::rle::tests::splice_ints" + }, + { + "id": "rust:columnar::column_range::rle::tests::splice_strings", + "source": "rust", + "file": "src/columnar/column_range/rle.rs", + "line": 207, + "name": "splice_strings", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::rle::tests::splice_strings" + }, + { + "id": "rust:columnar::column_range::value::tests::encode_row_wise_and_columnwise_equal", + "source": "rust", + "file": "src/columnar/column_range/value.rs", + "line": 532, + "name": "encode_row_wise_and_columnwise_equal", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::value::tests::encode_row_wise_and_columnwise_equal" + }, + { + "id": "rust:columnar::column_range::value::tests::test_initialize_splice", + "source": "rust", + "file": "src/columnar/column_range/value.rs", + "line": 508, + "name": "test_initialize_splice", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::value::tests::test_initialize_splice" + }, + { + "id": "rust:columnar::column_range::value::tests::test_splice_values", + "source": "rust", + "file": "src/columnar/column_range/value.rs", + "line": 515, + "name": "test_splice_values", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::value::tests::test_splice_values" + }, + { + "id": "rust:columnar::column_range::value::tests::test_value_uleb", + "source": "rust", + "file": "src/columnar/column_range/value.rs", + "line": 541, + "name": "test_value_uleb", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::column_range::value::tests::test_value_uleb" + }, + { + "id": "rust:columnar::encoding::boolean::tests::encode_decode_bools", + "source": "rust", + "file": "src/columnar/encoding/boolean.rs", + "line": 193, + "name": "encode_decode_bools", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::encoding::boolean::tests::encode_decode_bools" + }, + { + "id": "rust:columnar::encoding::leb128::tests::lebsize_examples", + "source": "rust", + "file": "src/columnar/encoding/leb128.rs", + "line": 57, + "name": "lebsize_examples", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::encoding::leb128::tests::lebsize_examples" + }, + { + "id": "rust:columnar::encoding::leb128::tests::test_lebsize", + "source": "rust", + "file": "src/columnar/encoding/leb128.rs", + "line": 37, + "name": "test_lebsize", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::encoding::leb128::tests::test_lebsize" + }, + { + "id": "rust:columnar::encoding::leb128::tests::test_ulebsize", + "source": "rust", + "file": "src/columnar/encoding/leb128.rs", + "line": 29, + "name": "test_ulebsize", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::encoding::leb128::tests::test_ulebsize" + }, + { + "id": "rust:columnar::encoding::leb128::tests::ulebsize_examples", + "source": "rust", + "file": "src/columnar/encoding/leb128.rs", + "line": 46, + "name": "ulebsize_examples", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "columnar::encoding::leb128::tests::ulebsize_examples" + }, + { + "id": "rust:complex_unicode_text", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1212, + "name": "complex_unicode_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/complex_unicode_text" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "complex_unicode_text" + }, + { + "id": "rust:concurrent_assignment_and_deletion_of_a_map_entry", + "source": "rust", + "file": "tests/test.rs", + "line": 494, + "name": "concurrent_assignment_and_deletion_of_a_map_entry", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Each actor independently assigns and deletes the same bounded key set before conflict comparison with Rust.", + "runtimeName": "concurrent_assignment_and_deletion_of_a_map_entry" + }, + { + "id": "rust:concurrent_assignment_and_deletion_of_list_entry", + "source": "rust", + "file": "tests/test.rs", + "line": 515, + "name": "concurrent_assignment_and_deletion_of_list_entry", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Concurrent list replacements and deletions are merged in both orders and compared element-by-element with Rust.", + "runtimeName": "concurrent_assignment_and_deletion_of_list_entry" + }, + { + "id": "rust:concurrent_deletion_of_same_list_element", + "source": "rust", + "file": "tests/test.rs", + "line": 608, + "name": "concurrent_deletion_of_same_list_element", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ConcurrentDeletionOfSameListElement" + ], + "rationale": "Both actors delete the same list element concurrently; the element is removed once and the surviving order matches on both engines.", + "runtimeName": "concurrent_deletion_of_same_list_element" + }, + { + "id": "rust:concurrent_insertions_at_different_list_positions", + "source": "rust", + "file": "tests/test.rs", + "line": 422, + "name": "concurrent_insertions_at_different_list_positions", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Independent actors insert throughout the same base list and both merge orders are compared with Rust.", + "runtimeName": "concurrent_insertions_at_different_list_positions" + }, + { + "id": "rust:concurrent_insertions_at_same_list_position", + "source": "rust", + "file": "tests/test.rs", + "line": 457, + "name": "concurrent_insertions_at_same_list_position", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "The bounded random index space repeatedly creates same-position concurrent insertions and verifies deterministic Rust ordering.", + "runtimeName": "concurrent_insertions_at_same_list_position" + }, + { + "id": "rust:concurrent_updates_at_different_levels", + "source": "rust", + "file": "tests/test.rs", + "line": 650, + "name": "concurrent_updates_at_different_levels", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ConcurrentUpdatesAtDifferentLevels" + ], + "rationale": "One actor edits a nested map while another deletes it; the deletion wins and only the sibling list remains on both engines.", + "runtimeName": "concurrent_updates_at_different_levels" + }, + { + "id": "rust:concurrent_updates_of_concurrently_deleted_objects", + "source": "rust", + "file": "tests/test.rs", + "line": 695, + "name": "concurrent_updates_of_concurrently_deleted_objects", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ConcurrentUpdatesOfConcurrentlyDeletedObjects" + ], + "rationale": "One actor updates a nested object another actor deleted concurrently; the deletion wins and the parent becomes empty on both engines.", + "runtimeName": "concurrent_updates_of_concurrently_deleted_objects" + }, + { + "id": "rust:concurrent_updates_of_same_field", + "source": "rust", + "file": "tests/test.rs", + "line": 249, + "name": "concurrent_updates_of_same_field", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Both actors repeatedly assign the same bounded key set before merge-order and Rust differential checks.", + "runtimeName": "concurrent_updates_of_same_field" + }, + { + "id": "rust:concurrent_updates_of_same_list_element", + "source": "rust", + "file": "tests/test.rs", + "line": 269, + "name": "concurrent_updates_of_same_list_element", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentListParity" + ], + "rationale": "Both actors replace shared base-list elements in deterministic randomized histories and compare the merged sequence with Rust.", + "runtimeName": "concurrent_updates_of_same_list_element" + }, + { + "id": "rust:concurrently_assigned_nested_maps_should_not_merge", + "source": "rust", + "file": "tests/test.rs", + "line": 390, + "name": "concurrently_assigned_nested_maps_should_not_merge", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ConcurrentlyAssignedNestedMapsShouldNotMerge" + ], + "rationale": "Two actors assign different maps to the same key; the maps do not merge and the winning map keeps exactly one key on both engines.", + "runtimeName": "concurrently_assigned_nested_maps_should_not_merge" + }, + { + "id": "rust:cursors", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 276, + "name": "cursors", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_UTF16CursorBoundariesMatchReference", + "TestPureGoDocument_DeletedCursorMatchesReference" + ], + "rationale": "Live and deleted cursor targets are compared with the UTF-16 Rust reference.", + "runtimeName": "cursors" + }, + { + "id": "rust:delete", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 365, + "name": "delete", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_Delete" + ], + "rationale": "Transactable::delete removes the element addressed by a UTF-16 index.", + "runtimeName": "delete" + }, + { + "id": "rust:delete_only_change", + "source": "rust", + "file": "tests/test.rs", + "line": 1234, + "name": "delete_only_change", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomMapParity" + ], + "rationale": "Random histories commit standalone map deletions and compare their hashes and resulting absence with Rust.", + "runtimeName": "delete_only_change" + }, + { + "id": "rust:deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character", + "source": "rust", + "file": "tests/text.rs", + "line": 1000, + "name": "deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_DeletingInMiddleOfMultibyteChar" + ], + "rationale": "Splice starts and deletion ends inside UTF-16 surrogate pairs are advanced to the following character boundary, with every intermediate text value compared directly against Rust.", + "runtimeName": "deleting_in_middle_of_multibyte_char_moves_the_cursor_to_after_the_character" + }, + { + "id": "rust:diff_emits_block_updates", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 729, + "name": "diff_emits_block_updates", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlock_DiffEmitsBlockUpdates" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output.", + "runtimeName": "diff_emits_block_updates" + }, + { + "id": "rust:diff_should_reverse_deletion_of_block_in_text_correctly", + "source": "rust", + "file": "tests/test.rs", + "line": 2234, + "name": "diff_should_reverse_deletion_of_block_in_text_correctly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiff_ReverseDeletionOfBlockInText" + ], + "rationale": "Diffing after a text-block deletion back to before re-inserts the block and materializes its properties identically on both engines.", + "runtimeName": "diff_should_reverse_deletion_of_block_in_text_correctly" + }, + { + "id": "rust:diff_should_reverse_deletion_of_object_in_list_correctly", + "source": "rust", + "file": "tests/test.rs", + "line": 2163, + "name": "diff_should_reverse_deletion_of_object_in_list_correctly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiff_ReverseDeletionOfObjectInList" + ], + "rationale": "Diffing after a list-object deletion back to before re-inserts the object and materializes its text identically on both engines.", + "runtimeName": "diff_should_reverse_deletion_of_object_in_list_correctly" + }, + { + "id": "rust:diff_should_reverse_deletion_of_object_in_map_correctly", + "source": "rust", + "file": "tests/test.rs", + "line": 2199, + "name": "diff_should_reverse_deletion_of_object_in_map_correctly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiff_ReverseDeletionOfObjectInMap" + ], + "rationale": "Diffing after a map-object deletion back to before re-puts the object and materializes its text identically on both engines.", + "runtimeName": "diff_should_reverse_deletion_of_object_in_map_correctly" + }, + { + "id": "rust:different_adjacent_marks", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 486, + "name": "different_adjacent_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeBoundaryMarks" + ], + "rationale": "Different marks on adjacent ranges remain separate at head and element anchors.", + "runtimeName": "different_adjacent_marks" + }, + { + "id": "rust:discard_orphans", + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "line": 49, + "name": "discard_orphans", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustOrphans_DiscardOrphans" + ], + "rationale": "Saving with retain_orphans disabled drops the orphan change on both engines, so a reload plus the missing dependency yields only the applicable value.", + "runtimeName": "discard_orphans" + }, + { + "id": "rust:does_not_interleave_sequence_insertions_at_same_position", + "source": "rust", + "file": "tests/test.rs", + "line": 724, + "name": "does_not_interleave_sequence_insertions_at_same_position", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Two multi-value insertions at the same position remain contiguous and match Rust ordering.", + "runtimeName": "does_not_interleave_sequence_insertions_at_same_position" + }, + { + "id": "rust:empty_marks_before_block_marker_dont_repeat_text", + "source": "rust", + "file": "tests/text.rs", + "line": 322, + "name": "empty_marks_before_block_marker_dont_repeat_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_EmptyMarksBeforeBlockMarker" + ], + "rationale": "Empty marks and text inserted around block markers do not duplicate text; both engines report two block spans followed by a single text span.", + "runtimeName": "empty_marks_before_block_marker_dont_repeat_text" + }, + { + "id": "rust:empty_spans_between_marks", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1295, + "name": "empty_spans_between_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/empty_spans_between_marks" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "empty_spans_between_marks" + }, + { + "id": "rust:exid::tests::objid_roundtrip", + "source": "rust", + "file": "src/exid.rs", + "line": 225, + "name": "objid_roundtrip", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "exid::tests::objid_roundtrip" + }, + { + "id": "rust:exid::tests::test_root_roundtrip", + "source": "rust", + "file": "src/exid.rs", + "line": 233, + "name": "test_root_roundtrip", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "exid::tests::test_root_roundtrip" + }, + { + "id": "rust:expand_marks_are_reported_in_patches", + "source": "rust", + "file": "tests/text.rs", + "line": 543, + "name": "expand_marks_are_reported_in_patches", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_ExpandMarksAreReportedInPatches" + ], + "rationale": "A both-expanding mark includes text inserted at either boundary and both incremental splice patches carry it.", + "runtimeName": "expand_marks_are_reported_in_patches" + }, + { + "id": "rust:fuzz_crashers", + "source": "rust", + "file": "tests/test.rs", + "line": 1475, + "name": "fuzz_crashers", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDecode_OfficialFuzzCrashersDoNotPanic", + "FuzzDecode" + ], + "rationale": "Every pinned upstream fuzz crasher is a deterministic test and fuzz seed for the native decoder.", + "runtimeName": "fuzz_crashers" + }, + { + "id": "rust:get", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 303, + "name": "get", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_Get" + ], + "rationale": "ReadDoc::get resolves a UTF-16 index across a multi-code-point grapheme to the correct element.", + "runtimeName": "get" + }, + { + "id": "rust:get_changes_with_hash_of_empty_change_produces_correct_result", + "source": "rust", + "file": "tests/test.rs", + "line": 2504, + "name": "get_changes_with_hash_of_empty_change_produces_correct_result", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_EmptyCommitChangesSince" + ], + "rationale": "ChangesSince returns the empty change from no heads and no changes when given its hash.", + "runtimeName": "get_changes_with_hash_of_empty_change_produces_correct_result" + }, + { + "id": "rust:get_marks_at_heads", + "source": "rust", + "file": "tests/test.rs", + "line": 1947, + "name": "get_marks_at_heads", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_GetMarksAtHeads" + ], + "rationale": "Marks active at a text index resolved at a historical frontier match the reference after the mark is later removed.", + "runtimeName": "get_marks_at_heads" + }, + { + "id": "rust:handle_repeated_out_of_order_changes", + "source": "rust", + "file": "tests/test.rs", + "line": 907, + "name": "handle_repeated_out_of_order_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_AppliesDependentChangesInAnyOrder", + "TestBackendMerge_AppliesReversedDependentChanges" + ], + "rationale": "Dependent changes are applied child-first in one batch, separately, and repeatedly.", + "runtimeName": "handle_repeated_out_of_order_changes" + }, + { + "id": "rust:has_our_changes", + "source": "rust", + "file": "tests/test.rs", + "line": 2340, + "name": "has_our_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_HasOurChanges" + ], + "rationale": "Two peers with concurrent changes synchronize until each contains the other's change and both converge to identical heads on both engines.", + "runtimeName": "has_our_changes" + }, + { + "id": "rust:hydrate::tests::simple_hydrate", + "source": "rust", + "file": "src/hydrate/tests.rs", + "line": 8, + "name": "simple_hydrate", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust hydrate::Value reader API; document hydration is covered via NewFrom and materialization. Rust-ergonomic reader, not cross-engine interop.", + "runtimeName": "hydrate::tests::simple_hydrate" + }, + { + "id": "rust:idempotent_update_spans", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1130, + "name": "idempotent_update_spans", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks_Idempotent" + ], + "rationale": "Repeating the same update_spans call produces no additional changes.", + "runtimeName": "idempotent_update_spans" + }, + { + "id": "rust:incorrect_patches_produced_when_isolating_and_integrating", + "source": "rust", + "file": "tests/text.rs", + "line": 869, + "name": "incorrect_patches_produced_when_isolating_and_integrating", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_IncorrectPatchesProducedWhenIsolatingAndIntegrating" + ], + "rationale": "An incremental diff across an isolate/integrate cycle with a conflicting object put resets to the isolate frontier and rebuilds: it emits deletes for the prior keys, conflicting puts, and a splice only for each winning object, matching the reference patch stream on both engines.", + "runtimeName": "incorrect_patches_produced_when_isolating_and_integrating" + }, + { + "id": "rust:increment_non_counter_list", + "source": "rust", + "file": "tests/test.rs", + "line": 1118, + "name": "increment_non_counter_list", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Both native and Rust reject incrementing an integer list element.", + "runtimeName": "increment_non_counter_list" + }, + { + "id": "rust:increment_non_counter_map", + "source": "rust", + "file": "tests/test.rs", + "line": 1085, + "name": "increment_non_counter_map", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Both native and Rust reject incrementing an integer map property.", + "runtimeName": "increment_non_counter_map" + }, + { + "id": "rust:incremental_splice_patches_include_marks", + "source": "rust", + "file": "tests/text.rs", + "line": 85, + "name": "incremental_splice_patches_include_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_IncrementalSplicePatchesIncludeMarks" + ], + "rationale": "Text spliced inside an expanding mark is reported as a splice_text patch carrying that mark, with no separate mark patch for the range growth.", + "runtimeName": "incremental_splice_patches_include_marks" + }, + { + "id": "rust:insert", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 560, + "name": "insert", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "insert" + }, + { + "id": "rust:insert_after_many_deletes", + "source": "rust", + "file": "tests/test.rs", + "line": 1418, + "name": "insert_after_many_deletes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ManyMapDeletes" + ], + "rationale": "One hundred map insert/delete pairs commit and materialize without index corruption in Go or Rust.", + "runtimeName": "insert_after_many_deletes" + }, + { + "id": "rust:inserting_text_near_deleted_marks", + "source": "rust", + "file": "tests/test.rs", + "line": 1884, + "name": "inserting_text_near_deleted_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_InsertingTextNearDeletedMarks" + ], + "rationale": "Inserting text around ranges whose marked elements were deleted yields the same active marks as the reference.", + "runtimeName": "inserting_text_near_deleted_marks" + }, + { + "id": "rust:insertion_after_a_deleted_list_element", + "source": "rust", + "file": "tests/test.rs", + "line": 564, + "name": "insertion_after_a_deleted_list_element", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_InsertAfterConcurrentDeleteMatchesReference" + ], + "rationale": "An insertion anchored after an element deleted concurrently remains visible in the correct position.", + "runtimeName": "insertion_after_a_deleted_list_element" + }, + { + "id": "rust:insertion_consistent_with_causality", + "source": "rust", + "file": "tests/test.rs", + "line": 834, + "name": "insertion_consistent_with_causality", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_InsertionConsistentWithCausality" + ], + "rationale": "Interleaved head insertions across repeated merges produce the causally ordered list one,two,three,four on both engines.", + "runtimeName": "insertion_consistent_with_causality" + }, + { + "id": "rust:insertions_after_noexpand_spans_are_not_marked", + "source": "rust", + "file": "tests/text.rs", + "line": 351, + "name": "insertions_after_noexpand_spans_are_not_marked", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_InsertionsAfterNoexpandSpans" + ], + "rationale": "Text appended after a block with no expanding mark in scope is reported by a diff as an unmarked splice.", + "runtimeName": "insertions_after_noexpand_spans_are_not_marked" + }, + { + "id": "rust:invalid_index", + "source": "rust", + "file": "tests/test.rs", + "line": 2388, + "name": "invalid_index", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_InvalidIndex" + ], + "rationale": "Inserting or putting beyond the end of a list is rejected by both engines while an in-bounds put succeeds.", + "runtimeName": "invalid_index" + }, + { + "id": "rust:iter::doc::tests::doc_iter", + "source": "rust", + "file": "src/iter/doc.rs", + "line": 532, + "name": "doc_iter", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust document-iterator API; document traversal is covered by current-state and value reads. Rust-ergonomic iterator, not cross-engine interop.", + "runtimeName": "iter::doc::tests::doc_iter" + }, + { + "id": "rust:iter::list_range::tests::list_range_bounds", + "source": "rust", + "file": "src/iter/list_range.rs", + "line": 374, + "name": "list_range_bounds", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustListRange_Bounds" + ], + "rationale": "List values and per-element conflict flags match the reference.", + "runtimeName": "iter::list_range::tests::list_range_bounds" + }, + { + "id": "rust:iter::list_range::tests::list_range_conflict", + "source": "rust", + "file": "src/iter/list_range.rs", + "line": 400, + "name": "list_range_conflict", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustListRange_Conflict" + ], + "rationale": "List values and per-element conflict flags match the reference.", + "runtimeName": "iter::list_range::tests::list_range_conflict" + }, + { + "id": "rust:large_patches_in_lists_are_correct", + "source": "rust", + "file": "tests/test.rs", + "line": 2120, + "name": "large_patches_in_lists_are_correct", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiff_LargePatchesInLists" + ], + "rationale": "A string list element counts as one index, so a run of 500 following objects is indexed identically in the native and reference diff patch streams.", + "runtimeName": "large_patches_in_lists_are_correct" + }, + { + "id": "rust:legacy::serde_impls::op::tests::test_deserialize_action", + "source": "rust", + "file": "src/legacy/serde_impls/op.rs", + "line": 289, + "name": "test_deserialize_action", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "legacy::serde_impls::op::tests::test_deserialize_action" + }, + { + "id": "rust:legacy::serde_impls::op::tests::test_deserialize_obj", + "source": "rust", + "file": "src/legacy/serde_impls/op.rs", + "line": 553, + "name": "test_deserialize_obj", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "legacy::serde_impls::op::tests::test_deserialize_obj" + }, + { + "id": "rust:legacy::serde_impls::op::tests::test_round_trips", + "source": "rust", + "file": "src/legacy/serde_impls/op.rs", + "line": 618, + "name": "test_round_trips", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "legacy::serde_impls::op::tests::test_round_trips" + }, + { + "id": "rust:legacy::serde_impls::op::tests::test_serialize_key", + "source": "rust", + "file": "src/legacy/serde_impls/op.rs", + "line": 591, + "name": "test_serialize_key", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "legacy::serde_impls::op::tests::test_serialize_key" + }, + { + "id": "rust:length", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 186, + "name": "length", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_Length" + ], + "rationale": "ReadDoc::length reports UTF-16 code units for text; a family emoji counts as 11 units.", + "runtimeName": "length" + }, + { + "id": "rust:list_counter_del", + "source": "rust", + "file": "tests/test.rs", + "line": 974, + "name": "list_counter_del", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ListCounterDel" + ], + "rationale": "Three actors write conflicting counters and an integer to the same list elements; increments apply to every conflicting counter and delete the non-counter, and the conflict sets, lengths, and reloads match the reference engine.", + "runtimeName": "list_counter_del" + }, + { + "id": "rust:list_deletion", + "source": "rust", + "file": "tests/test.rs", + "line": 151, + "name": "list_deletion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomListParity" + ], + "rationale": "Random list histories repeatedly delete first, middle, and last elements and compare every remaining value with Rust.", + "runtimeName": "list_deletion" + }, + { + "id": "rust:load", + "source": "rust", + "file": "tests/test.rs", + "line": 1502, + "name": "load", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDecode_OfficialStorageCorpus" + ], + "rationale": "Ordered, compressed, and out-of-order official multi-change fixtures all load successfully.", + "runtimeName": "load" + }, + { + "id": "rust:load_doc_with_deleted_objects", + "source": "rust", + "file": "tests/test.rs", + "line": 1402, + "name": "load_doc_with_deleted_objects", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_DeletedObjectsSaveLoad" + ], + "rationale": "Deleted list, text, map, and table objects save and load in native and Rust engines with an empty root.", + "runtimeName": "load_doc_with_deleted_objects" + }, + { + "id": "rust:load_incremental_change_without_deps_throws", + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "line": 72, + "name": "load_incremental_change_without_deps_throws", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustOrphans_LoadIncrementalChangeWithoutDepsThrows" + ], + "rationale": "Loading a bare change chunk whose dependencies are absent is rejected by both engines.", + "runtimeName": "load_incremental_change_without_deps_throws" + }, + { + "id": "rust:load_incremental_with_corrupted_tail", + "source": "rust", + "file": "tests/test.rs", + "line": 1383, + "name": "load_incremental_with_corrupted_tail", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_IncrementalLoadIgnoresCorruptTail" + ], + "rationale": "Native and Rust apply the complete valid prefix and ignore the corrupt trailing fragment.", + "runtimeName": "load_incremental_with_corrupted_tail" + }, + { + "id": "rust:local_patches_created_for_marks", + "source": "rust", + "file": "tests/text.rs", + "line": 164, + "name": "local_patches_created_for_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_LocalPatchesCreatedForMarks" + ], + "rationale": "Materializing marked text through the diff cursor splits it into one splice_text patch per mark run, each carrying the active marks.", + "runtimeName": "local_patches_created_for_marks" + }, + { + "id": "rust:make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head", + "source": "rust", + "file": "tests/test.rs", + "line": 2425, + "name": "make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_LoadIncrementalWithCommonHead" + ], + "rationale": "Incremental loads that share a common head are not skipped; both engines end with the expected merged two-head frontier.", + "runtimeName": "make_sure_load_incremental_doesnt_skip_a_load_with_a_common_head" + }, + { + "id": "rust:many_marks_on_same_text", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 723, + "name": "many_marks_on_same_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/many_marks_on_same_text" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "many_marks_on_same_text" + }, + { + "id": "rust:mark", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 221, + "name": "mark", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_MarkAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently author the same mark and cross-load the resulting marked text.", + "runtimeName": "mark" + }, + { + "id": "rust:mark_contracts", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 231, + "name": "mark_contracts", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_contracts" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_contracts" + }, + { + "id": "rust:mark_created_after_insertion", + "source": "rust", + "file": "tests/text.rs", + "line": 144, + "name": "mark_created_after_insertion", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_MarkCreatedAfterInsertion" + ], + "rationale": "Two disjoint strong marks created after insertion produce identical spans on both engines.", + "runtimeName": "mark_created_after_insertion" + }, + { + "id": "rust:mark_ends_at_block_boundary", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 661, + "name": "mark_ends_at_block_boundary", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/mark_ends_at_block_boundary" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "mark_ends_at_block_boundary" + }, + { + "id": "rust:mark_expands", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 197, + "name": "mark_expands", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_expands" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_expands" + }, + { + "id": "rust:mark_on_empty_string", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 525, + "name": "mark_on_empty_string", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_on_empty_string" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_on_empty_string" + }, + { + "id": "rust:mark_on_whitespace", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 545, + "name": "mark_on_whitespace", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_on_whitespace" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_on_whitespace" + }, + { + "id": "rust:mark_patches_at_end_of_text", + "source": "rust", + "file": "tests/test_mark_patches.rs", + "line": 9, + "name": "mark_patches_at_end_of_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustMarkPatches_AtEndOfText" + ], + "rationale": "A mark loaded incrementally into another document produces a single Mark patch through the diff cursor.", + "runtimeName": "mark_patches_at_end_of_text" + }, + { + "id": "rust:mark_shifts_position", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 277, + "name": "mark_shifts_position", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_shifts_position" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_shifts_position" + }, + { + "id": "rust:mark_spans_across_block", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 616, + "name": "mark_spans_across_block", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/mark_spans_across_block" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "mark_spans_across_block" + }, + { + "id": "rust:mark_splits", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 323, + "name": "mark_splits", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "A single mark split by unmarking materializes as separate marked spans.", + "runtimeName": "mark_splits" + }, + { + "id": "rust:mark_value_changes_color", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 794, + "name": "mark_value_changes_color", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_value_changes_color" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_value_changes_color" + }, + { + "id": "rust:mark_value_changes_link_url", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 756, + "name": "mark_value_changes_link_url", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_value_changes_link_url" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_value_changes_link_url" + }, + { + "id": "rust:mark_value_type_changes", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 829, + "name": "mark_value_type_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/mark_value_type_changes" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "mark_value_type_changes" + }, + { + "id": "rust:marks", + "source": "rust", + "file": "tests/test.rs", + "line": 1742, + "name": "marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustMarks_ExpansionAndUnmark" + ], + "rationale": "A both-expanding mark grows at its end, unmark removes only the original prefix, and prepended text remains unmarked.", + "runtimeName": "marks" + }, + { + "id": "rust:marks_are_okay", + "source": "rust", + "file": "tests/text.rs", + "line": 660, + "name": "marks_are_okay", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_MarksAreOkay" + ], + "rationale": "Randomized insert/delete/split-block/mark sequences keep spans consolidated (no adjacent identical mark sets) and reproduce the accumulated text on both engines, matching the upstream property invariants.", + "runtimeName": "marks_are_okay" + }, + { + "id": "rust:marks_in_spans_cross_block_markers", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 433, + "name": "marks_in_spans_cross_block_markers", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_MarksInSpansCrossBlockMarkers" + ], + "rationale": "A mark spanning text split by a block marker is reported as two marked text spans around the block, identically on both engines.", + "runtimeName": "marks_in_spans_cross_block_markers" + }, + { + "id": "rust:marks_on_combining_characters", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 978, + "name": "marks_on_combining_characters", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/marks_on_combining_characters" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "marks_on_combining_characters" + }, + { + "id": "rust:marks_on_emoji", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 947, + "name": "marks_on_emoji", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeUnicodeMarks" + ], + "rationale": "Marked emoji ranges and subsequent UTF-16 edits materialize with Rust parity.", + "runtimeName": "marks_on_emoji" + }, + { + "id": "rust:marks_on_spans_respect_heads", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 395, + "name": "marks_on_spans_respect_heads", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlock_MarksOnSpansRespectHeads" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output.", + "runtimeName": "marks_on_spans_respect_heads" + }, + { + "id": "rust:marks_survive_block_updates", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1424, + "name": "marks_survive_block_updates", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/marks_survive_block_updates" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "marks_survive_block_updates" + }, + { + "id": "rust:marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches", + "source": "rust", + "file": "tests/text.rs", + "line": 418, + "name": "marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_CrossPageMarksNotDoubleCounted" + ], + "rationale": "A non-expanding mark crossing an operation-tree page boundary does not leak onto text appended after repeated block insertions.", + "runtimeName": "marks_which_cross_optree_boundaries_are_not_double_counted_in_splice_patches" + }, + { + "id": "rust:marks_with_different_values_same_name", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1332, + "name": "marks_with_different_values_same_name", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/marks_with_different_values_same_name" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "marks_with_different_values_same_name" + }, + { + "id": "rust:marks_with_expand_none_at_boundaries", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 906, + "name": "marks_with_expand_none_at_boundaries", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/marks_with_expand_none_at_boundaries" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "marks_with_expand_none_at_boundaries" + }, + { + "id": "rust:merge_after_noop_then_real_put", + "source": "rust", + "file": "tests/test.rs", + "line": 2701, + "name": "merge_after_noop_then_real_put", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_NoOpMergeAndEqualPutMatchReference" + ], + "rationale": "A fork records a no-op change followed by a real assignment, then merges and reloads in Go and Rust.", + "runtimeName": "merge_after_noop_then_real_put" + }, + { + "id": "rust:merge_concurrent_map_prop_updates", + "source": "rust", + "file": "tests/test.rs", + "line": 172, + "name": "merge_concurrent_map_prop_updates", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RandomConcurrentMapParity" + ], + "rationale": "Twenty deterministic multi-change histories update shared map properties concurrently and compare every conflict with an independently merged Rust history.", + "runtimeName": "merge_concurrent_map_prop_updates" + }, + { + "id": "rust:merge_panic_after_putting_value_equal_to_initial_value", + "source": "rust", + "file": "tests/test.rs", + "line": 2671, + "name": "merge_panic_after_putting_value_equal_to_initial_value", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_NoOpMergeAndEqualPutMatchReference" + ], + "rationale": "An equal-value assignment creates no operation and does not panic when merged with a real fork update.", + "runtimeName": "merge_panic_after_putting_value_equal_to_initial_value" + }, + { + "id": "rust:merge_produces_block_insertion_diffs", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 785, + "name": "merge_produces_block_insertion_diffs", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlock_MergeProducesBlockInsertionDiffs" + ], + "rationale": "Historical spans and block insertion diffs match the reference patch and span output.", + "runtimeName": "merge_produces_block_insertion_diffs" + }, + { + "id": "rust:missing_actors_when_docs_are_forked", + "source": "rust", + "file": "tests/test.rs", + "line": 2271, + "name": "missing_actors_when_docs_are_forked", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ForkMatchesReference" + ], + "rationale": "Forked histories with a new actor save, merge, and resolve in both native and Rust engines.", + "runtimeName": "missing_actors_when_docs_are_forked" + }, + { + "id": "rust:multiple_batch_inserts", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 378, + "name": "multiple_batch_inserts", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatch_MultipleInserts" + ], + "rationale": "Three sequential batch-created maps each retain their scalar fields on both engines.", + "runtimeName": "multiple_batch_inserts" + }, + { + "id": "rust:multiple_marks_different_expand_behaviors", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 864, + "name": "multiple_marks_different_expand_behaviors", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/multiple_marks_different_expand_behaviors" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "multiple_marks_different_expand_behaviors" + }, + { + "id": "rust:mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id", + "source": "rust", + "file": "tests/test.rs", + "line": 784, + "name": "mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "The greater actor's insertion chunk is ordered before the lesser actor's chunk in Go and Rust.", + "runtimeName": "mutliple_insertions_at_same_list_position_with_insertion_by_greater_actor_id" + }, + { + "id": "rust:mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id", + "source": "rust", + "file": "tests/test.rs", + "line": 809, + "name": "mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentListOrderingMatchesReference" + ], + "rationale": "Actor-order-independent construction converges to the same chunk ordering in Go and Rust.", + "runtimeName": "mutliple_insertions_at_same_list_position_with_insertion_by_lesser_actor_id" + }, + { + "id": "rust:negative_64", + "source": "rust", + "file": "tests/test.rs", + "line": 1515, + "name": "negative_64", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_AllScalarTypesMatchReference" + ], + "rationale": "The minimum signed 64-bit scalar round-trips through native, Rust, and native-to-Rust loading.", + "runtimeName": "negative_64" + }, + { + "id": "rust:nested_marks", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 691, + "name": "nested_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/nested_marks" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "nested_marks" + }, + { + "id": "rust:no_conflict_on_repeated_assignment", + "source": "rust", + "file": "tests/test.rs", + "line": 93, + "name": "no_conflict_on_repeated_assignment", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_StringParity" + ], + "rationale": "Repeated assignments in one change produce exactly one visible value in Go and Rust.", + "runtimeName": "no_conflict_on_repeated_assignment" + }, + { + "id": "rust:noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions", + "source": "rust", + "file": "tests/text.rs", + "line": 505, + "name": "noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_NoexpandMarksAtEndOfText" + ], + "rationale": "Text appended after a non-expanding mark does not inherit it, so the splice patch carries no marks.", + "runtimeName": "noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions" + }, + { + "id": "rust:obj_id_64bits", + "source": "rust", + "file": "tests/test.rs", + "line": 1521, + "name": "obj_id_64bits", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDecode_Official64BitObjectIDs" + ], + "rationale": "Official change and document fixtures with a 2^42 object operation ID either reject safely or preserve the full ID.", + "runtimeName": "obj_id_64bits" + }, + { + "id": "rust:observe_counter_change_application", + "source": "rust", + "file": "src/automerge/tests.rs", + "line": 1915, + "name": "observe_counter_change_application", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustAutomerge_ObserveCounterChangeApplication" + ], + "rationale": "Applying a change that creates and increments a counter yields identical incremental patches on both engines. The pinned reference (automerge 0.10.0 embedded as WASM) collapses the create-and-increment sequence into a single put of the materialized value through diff_incremental rather than emitting per-operation patches, and native matches that reference behavior exactly.", + "runtimeName": "observe_counter_change_application" + }, + { + "id": "rust:op_set2::change::batch::tests::batch_counter_list_patch", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1630, + "name": "batch_counter_list_patch", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::batch_counter_list_patch" + }, + { + "id": "rust:op_set2::change::batch::tests::batch_list_patch", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1684, + "name": "batch_list_patch", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::batch_list_patch" + }, + { + "id": "rust:op_set2::change::batch::tests::batch_marks_patch", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1773, + "name": "batch_marks_patch", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::batch_marks_patch" + }, + { + "id": "rust:op_set2::change::batch::tests::batch_text_patch", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1732, + "name": "batch_text_patch", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::batch_text_patch" + }, + { + "id": "rust:op_set2::change::batch::tests::conflicts_with_isolate", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1944, + "name": "conflicts_with_isolate", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::conflicts_with_isolate" + }, + { + "id": "rust:op_set2::change::batch::tests::fuzz_batch_list_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1375, + "name": "fuzz_batch_list_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::fuzz_batch_list_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::fuzz_batch_map_counter_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1539, + "name": "fuzz_batch_map_counter_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::fuzz_batch_map_counter_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::fuzz_batch_map1_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1425, + "name": "fuzz_batch_map1_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::fuzz_batch_map1_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::fuzz_batch_map2_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1469, + "name": "fuzz_batch_map2_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::fuzz_batch_map2_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::list_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1213, + "name": "list_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::list_batch_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::list_element_conflict", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1904, + "name": "list_element_conflict", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::list_element_conflict" + }, + { + "id": "rust:op_set2::change::batch::tests::map_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1168, + "name": "map_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::map_batch_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::map_key_conflict", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1864, + "name": "map_key_conflict", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::map_key_conflict" + }, + { + "id": "rust:op_set2::change::batch::tests::multi_insert_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1317, + "name": "multi_insert_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::multi_insert_batch_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::multi_put_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1295, + "name": "multi_put_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::multi_put_batch_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::multi_update_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1342, + "name": "multi_update_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::multi_update_batch_apply" + }, + { + "id": "rust:op_set2::change::batch::tests::text_batch_apply", + "source": "rust", + "file": "src/op_set2/change/batch.rs", + "line": 1260, + "name": "text_batch_apply", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::change::batch::tests::text_batch_apply" + }, + { + "id": "rust:op_set2::meta::tests::column_data_meta_group", + "source": "rust", + "file": "src/op_set2/meta.rs", + "line": 132, + "name": "column_data_meta_group", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::meta::tests::column_data_meta_group" + }, + { + "id": "rust:op_set2::op_set::mark_index::tests::column_data_delta_simple", + "source": "rust", + "file": "src/op_set2/op_set/mark_index.rs", + "line": 291, + "name": "column_data_delta_simple", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::mark_index::tests::column_data_delta_simple" + }, + { + "id": "rust:op_set2::op_set::op_iter::tests::obj_id_iter_seek", + "source": "rust", + "file": "src/op_set2/op_set/op_iter.rs", + "line": 1194, + "name": "obj_id_iter_seek", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::op_iter::tests::obj_id_iter_seek" + }, + { + "id": "rust:op_set2::op_set::op_iter::tests::skip_op_ids", + "source": "rust", + "file": "src/op_set2/op_set/op_iter.rs", + "line": 1080, + "name": "skip_op_ids", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::op_iter::tests::skip_op_ids" + }, + { + "id": "rust:op_set2::op_set::tests::column_data_basic_iteration", + "source": "rust", + "file": "src/op_set2/op_set.rs", + "line": 1405, + "name": "column_data_basic_iteration", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::tests::column_data_basic_iteration" + }, + { + "id": "rust:op_set2::op_set::tests::column_data_iter_range", + "source": "rust", + "file": "src/op_set2/op_set.rs", + "line": 1539, + "name": "column_data_iter_range", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::tests::column_data_iter_range" + }, + { + "id": "rust:op_set2::op_set::tests::column_data_op_iterators", + "source": "rust", + "file": "src/op_set2/op_set.rs", + "line": 1622, + "name": "column_data_op_iterators", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::tests::column_data_op_iterators" + }, + { + "id": "rust:op_set2::op_set::tests::suspend_resume_op_set_iter", + "source": "rust", + "file": "src/op_set2/op_set.rs", + "line": 1353, + "name": "suspend_resume_op_set_iter", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::op_set::tests::suspend_resume_op_set_iter" + }, + { + "id": "rust:op_set2::parents::tests::test_invisible_parents", + "source": "rust", + "file": "src/op_set2/parents.rs", + "line": 100, + "name": "test_invisible_parents", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "op_set2::parents::tests::test_invisible_parents" + }, + { + "id": "rust:ops_on_wrong_objets", + "source": "rust", + "file": "tests/test.rs", + "line": 1449, + "name": "ops_on_wrong_objets", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_WrongObjectOperationsMatchReference" + ], + "rationale": "Map writes on list/text objects and sequence writes on maps reject consistently with Rust.", + "runtimeName": "ops_on_wrong_objets" + }, + { + "id": "rust:overlapping_marks_add_third_mark", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 134, + "name": "overlapping_marks_add_third_mark", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_add_third_mark" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "overlapping_marks_add_third_mark" + }, + { + "id": "rust:overlapping_marks_change_boundaries", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 73, + "name": "overlapping_marks_change_boundaries", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_change_boundaries" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "overlapping_marks_change_boundaries" + }, + { + "id": "rust:overlapping_marks_remove_one_keep_other", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 20, + "name": "overlapping_marks_remove_one_keep_other", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/overlapping_marks_remove_one_keep_other" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "overlapping_marks_remove_one_keep_other" + }, + { + "id": "rust:overlong_leb", + "source": "rust", + "file": "tests/test.rs", + "line": 1492, + "name": "overlong_leb", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestReaderULEB_RejectsNonCanonicalValue" + ], + "rationale": "The native reader rejects an overlong, non-minimal unsigned LEB128 representation.", + "runtimeName": "overlong_leb" + }, + { + "id": "rust:owned_transaction_with_patch_log_from_another_document_does_not_panic", + "source": "rust", + "file": "tests/test.rs", + "line": 71, + "name": "owned_transaction_with_patch_log_from_another_document_does_not_panic", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust API safety guard: using a PatchLog that belongs to another document returns an error instead of panicking. A Rust-binding safety contract with no cross-engine wire or state meaning.", + "runtimeName": "owned_transaction_with_patch_log_from_another_document_does_not_panic" + }, + { + "id": "rust:patch_delete", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 548, + "name": "patch_delete", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_PatchDelete" + ], + "rationale": "A delete on text produces a DeleteSeq patch at a UTF-16 index with length one.", + "runtimeName": "patch_delete" + }, + { + "id": "rust:patch_insert", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 458, + "name": "patch_insert", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_PatchInsert" + ], + "rationale": "An insert on text produces a SpliceText patch addressed by UTF-16 code units.", + "runtimeName": "patch_insert" + }, + { + "id": "rust:patch_mark", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 593, + "name": "patch_mark", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_PatchMark" + ], + "rationale": "A mark produces a Mark patch whose start and end are UTF-16 code units, and the diff carries added, removed (null-valued), and changed marks.", + "runtimeName": "patch_mark" + }, + { + "id": "rust:patch_put_seq", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 412, + "name": "patch_put_seq", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_PatchPutSeq" + ], + "rationale": "An in-place text put reported through the incremental diff cursor produces a PutSeq patch at a UTF-16 index.", + "runtimeName": "patch_put_seq" + }, + { + "id": "rust:patch_splice_text", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 503, + "name": "patch_splice_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_PatchSpliceText" + ], + "rationale": "A splice on text produces a SpliceText patch addressed by UTF-16 code units.", + "runtimeName": "patch_splice_text" + }, + { + "id": "rust:put", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 327, + "name": "put", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_Put" + ], + "rationale": "Transactable::put replaces the element at a UTF-16 index, materializing the winning value for text.", + "runtimeName": "put" + }, + { + "id": "rust:regression_insert_opid", + "source": "rust", + "file": "tests/test.rs", + "line": 1629, + "name": "regression_insert_opid", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_RegressionInsertOpid" + ], + "rationale": "Interleaved insert-then-overwrite operations round-trip through a cross-engine save/load with every list value preserved and identical heads.", + "runtimeName": "regression_insert_opid" + }, + { + "id": "rust:regression_nth_miscount", + "source": "rust", + "file": "tests/test.rs", + "line": 1574, + "name": "regression_nth_miscount", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_RegressionNthMiscount" + ], + "rationale": "A 30-element list of nested maps is indexed after insert-then-replace at every position; both engines resolve each element to the expected map and value with identical heads.", + "runtimeName": "regression_nth_miscount" + }, + { + "id": "rust:regression_nth_miscount_smaller", + "source": "rust", + "file": "tests/test.rs", + "line": 1603, + "name": "regression_nth_miscount_smaller", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_RegressionNthMiscountSmaller" + ], + "rationale": "A list spanning several op-tree nodes (B*4 elements) is inserted then overwritten at each index; both engines read back every scalar with identical heads.", + "runtimeName": "regression_nth_miscount_smaller" + }, + { + "id": "rust:removed_marks_should_not_appear_in_get_marks", + "source": "rust", + "file": "tests/text.rs", + "line": 836, + "name": "removed_marks_should_not_appear_in_get_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_RemovedMarksNotInGetMarks" + ], + "rationale": "A mark removed by a null value does not appear in the active mark set on either engine.", + "runtimeName": "removed_marks_should_not_appear_in_get_marks" + }, + { + "id": "rust:removing_all_text_from_marked_span", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 582, + "name": "removing_all_text_from_marked_span", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/removing_all_text_from_marked_span" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "removing_all_text_from_marked_span" + }, + { + "id": "rust:repeated_list_assignment_which_resolves_conflict_not_ignored", + "source": "rust", + "file": "tests/test.rs", + "line": 126, + "name": "repeated_list_assignment_which_resolves_conflict_not_ignored", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_RepeatedListAssignmentResolvesConflict" + ], + "rationale": "A list element is assigned across a merge and then reassigned; both engines resolve to the single winning value and agree on heads.", + "runtimeName": "repeated_list_assignment_which_resolves_conflict_not_ignored" + }, + { + "id": "rust:repeated_map_assignment_which_resolves_conflict_not_ignored", + "source": "rust", + "file": "tests/test.rs", + "line": 106, + "name": "repeated_map_assignment_which_resolves_conflict_not_ignored", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "A merged two-value conflict is overwritten and verified to contain exactly the resolving value.", + "runtimeName": "repeated_map_assignment_which_resolves_conflict_not_ignored" + }, + { + "id": "rust:reproduce_clock_cache_bug", + "source": "rust", + "file": "tests/test.rs", + "line": 2523, + "name": "reproduce_clock_cache_bug", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_ReproduceClockCacheBug" + ], + "rationale": "Merging many branches by distinct actors leaves no change outside the merged frontier, exercising vector-clock ancestry.", + "runtimeName": "reproduce_clock_cache_bug" + }, + { + "id": "rust:rollback_with_no_ops", + "source": "rust", + "file": "tests/test.rs", + "line": 2012, + "name": "rollback_with_no_ops", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_RollbackMatchesReference" + ], + "rationale": "Rollback with no pending operations returns zero in native and Rust engines.", + "runtimeName": "rollback_with_no_ops" + }, + { + "id": "rust:rollback_with_several_actors", + "source": "rust", + "file": "tests/test.rs", + "line": 2040, + "name": "rollback_with_several_actors", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_RollbackWithSeveralActors" + ], + "rationale": "Uncommitted edits by a third forked actor are rolled back, leaving the document byte-identical to the forked-from state on both engines.", + "runtimeName": "rollback_with_several_actors" + }, + { + "id": "rust:save_and_load_incremented_counter", + "source": "rust", + "file": "tests/test.rs", + "line": 1363, + "name": "save_and_load_incremented_counter", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Incremented counters are saved by Go, loaded by Rust, and compared after concurrent merging.", + "runtimeName": "save_and_load_incremented_counter" + }, + { + "id": "rust:save_and_reload_create_object", + "source": "rust", + "file": "tests/test.rs", + "line": 1270, + "name": "save_and_reload_create_object", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_LoadedObjectRemainsEditable" + ], + "rationale": "A list created without children is saved, loaded, mutated under a new actor, saved again, and read by Rust.", + "runtimeName": "save_and_reload_create_object" + }, + { + "id": "rust:save_and_restore_empty", + "source": "rust", + "file": "tests/test.rs", + "line": 863, + "name": "save_and_restore_empty", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_EmptySnapshotLoadsInReference" + ], + "rationale": "An empty native document saves and loads in Rust with no heads.", + "runtimeName": "save_and_restore_empty" + }, + { + "id": "rust:save_orphaned_changes", + "source": "rust", + "file": "tests/test_save_load_orphans.rs", + "line": 30, + "name": "save_orphaned_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustOrphans_SaveOrphanedChanges" + ], + "rationale": "A retained orphan change survives a save/load round trip on both engines, so applying the missing dependency afterwards resolves it to the final value.", + "runtimeName": "save_orphaned_changes" + }, + { + "id": "rust:save_restore_complex_transactional", + "source": "rust", + "file": "tests/test.rs", + "line": 927, + "name": "save_restore_complex_transactional", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_SaveRestoreComplexTransactional" + ], + "rationale": "The transactional variant groups writes into single commits; the reloaded document exposes the same conflicting titles and boolean on both engines.", + "runtimeName": "save_restore_complex_transactional" + }, + { + "id": "rust:save_restore_complex1", + "source": "rust", + "file": "tests/test.rs", + "line": 871, + "name": "save_restore_complex1", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_SaveRestoreComplex1" + ], + "rationale": "A todo list with a conflicting title survives save and reload; both engines expose both conflicting titles and the retained boolean.", + "runtimeName": "save_restore_complex1" + }, + { + "id": "rust:save_with_empty_commits", + "source": "rust", + "file": "tests/test.rs", + "line": 2100, + "name": "save_with_empty_commits", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_EmptyCommitTimeParity", + "TestDocument_EmptyCommitChangesSince" + ], + "rationale": "Sequences of empty changes save, load in Rust, preserve metadata, and retain their head hashes.", + "runtimeName": "save_with_empty_commits" + }, + { + "id": "rust:save_with_ops_which_reference_actors_only_via_delete", + "source": "rust", + "file": "tests/test.rs", + "line": 2069, + "name": "save_with_ops_which_reference_actors_only_via_delete", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_SaveWithOpsReferencingActorsOnlyViaDelete" + ], + "rationale": "A merged delete op references a fork's actor only through successors; the document still saves and reloads cleanly across both engines.", + "runtimeName": "save_with_ops_which_reference_actors_only_via_delete" + }, + { + "id": "rust:sequence_tree::tests::insert", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 560, + "name": "insert", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::insert" + }, + { + "id": "rust:sequence_tree::tests::insert_book", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 573, + "name": "insert_book", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::insert_book" + }, + { + "id": "rust:sequence_tree::tests::insert_book_vec", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 582, + "name": "insert_book_vec", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::insert_book_vec" + }, + { + "id": "rust:sequence_tree::tests::proptest_insert", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 609, + "name": "proptest_insert", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::proptest_insert" + }, + { + "id": "rust:sequence_tree::tests::proptest_remove", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 633, + "name": "proptest_remove", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::proptest_remove" + }, + { + "id": "rust:sequence_tree::tests::push_back", + "source": "rust", + "file": "src/sequence_tree.rs", + "line": 546, + "name": "push_back", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "sequence_tree::tests::push_back" + }, + { + "id": "rust:simple_bad_saveload", + "source": "rust", + "file": "tests/test.rs", + "line": 1428, + "name": "simple_bad_saveload", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRust_SimpleBadSaveload" + ], + "rationale": "An empty commit interleaved between real changes does not corrupt the save/load round trip on either engine.", + "runtimeName": "simple_bad_saveload" + }, + { + "id": "rust:simple_update_text", + "source": "rust", + "file": "tests/text.rs", + "line": 17, + "name": "simple_update_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_SimpleUpdateText" + ], + "rationale": "update_text computes a minimal grapheme diff so concurrent edits to disjoint words merge into a combined document, matching the reference change history.", + "runtimeName": "simple_update_text" + }, + { + "id": "rust:spans_are_consolidated_in_the_presence_of_zero_length_spans", + "source": "rust", + "file": "tests/text.rs", + "line": 298, + "name": "spans_are_consolidated_in_the_presence_of_zero_length_spans", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_SpansConsolidatedWithZeroLengthSpans" + ], + "rationale": "Zero-length marks do not fragment the span stream; both engines report a single consolidated text span.", + "runtimeName": "spans_are_consolidated_in_the_presence_of_zero_length_spans" + }, + { + "id": "rust:spans_consolidates_marks_which_are_empty_due_to_deleted_marks", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 496, + "name": "spans_consolidates_marks_which_are_empty_due_to_deleted_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_SpansConsolidateEmptyDueToDeletedMarks" + ], + "rationale": "Overlapping bold/italic marks partially removed consolidate into the expected three spans on both engines.", + "runtimeName": "spans_consolidates_marks_which_are_empty_due_to_deleted_marks" + }, + { + "id": "rust:spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 555, + "name": "spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_SpansConsolidateDeletedThenEmptyMarks" + ], + "rationale": "Marking then unmarking a leading range consolidates back to a single unmarked span on both engines.", + "runtimeName": "spans_consolidates_marks_with_deleted_marks_followed_by_empty_marks" + }, + { + "id": "rust:spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 588, + "name": "spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_SpansConsolidateEmptyThenDeletedMarks" + ], + "rationale": "Marking then unmarking a trailing range consolidates back to a single span on both engines.", + "runtimeName": "spans_consolidates_marks_with_empty_marks_followed_by_deleted_marks" + }, + { + "id": "rust:splice_deeply_nested", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 962, + "name": "splice_deeply_nested", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_deeply_nested" + }, + { + "id": "rust:splice_delete_and_insert", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 906, + "name": "splice_delete_and_insert", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_delete_and_insert" + }, + { + "id": "rust:splice_delete_only", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 928, + "name": "splice_delete_only", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_delete_only" + }, + { + "id": "rust:splice_insert_mixed", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 883, + "name": "splice_insert_mixed", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_insert_mixed" + }, + { + "id": "rust:splice_insert_objects", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 859, + "name": "splice_insert_objects", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_insert_objects" + }, + { + "id": "rust:splice_insert_scalars", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 839, + "name": "splice_insert_scalars", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_insert_scalars" + }, + { + "id": "rust:splice_merges_correctly", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 1013, + "name": "splice_merges_correctly", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBatch_SpliceMergesCorrectly" + ], + "rationale": "Concurrent hydrated splices into a shared list merge to length three with the shared element retained.", + "runtimeName": "splice_merges_correctly" + }, + { + "id": "rust:splice_survives_save_load", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 989, + "name": "splice_survives_save_load", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_survives_save_load" + }, + { + "id": "rust:splice_text", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 201, + "name": "splice_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestText_SpliceUsesUTF16Offsets", + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "Random and deterministic UTF-16 splices are checked after every operation against Rust.", + "runtimeName": "splice_text" + }, + { + "id": "rust:splice_with_text", + "source": "rust", + "file": "tests/batch_insert.rs", + "line": 944, + "name": "splice_with_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_HydrateSpliceMatchesReference" + ], + "rationale": "Hydrated list splice deletion, insertion, nested objects, text, replacement, and Rust parity are exercised.", + "runtimeName": "splice_with_text" + }, + { + "id": "rust:splicing_into_multibyte_characters", + "source": "rust", + "file": "tests/text.rs", + "line": 1020, + "name": "splicing_into_multibyte_characters", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestText_SpliceUsesUTF16Offsets", + "TestPureGoDocument_RandomTextParity" + ], + "rationale": "UTF-16 splices include surrogate-pair insertion and deletion with reference parity.", + "runtimeName": "splicing_into_multibyte_characters" + }, + { + "id": "rust:split_block", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 384, + "name": "split_block", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTextEncoding_SplitBlock" + ], + "rationale": "Transactable::split_block splits text at a UTF-16 index between grapheme clusters.", + "runtimeName": "split_block" + }, + { + "id": "rust:stats_smoke_test", + "source": "rust", + "file": "tests/test.rs", + "line": 2376, + "name": "stats_smoke_test", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_StatsMatchReference" + ], + "rationale": "Two committed puts report two changes, two ops, and one actor identically on the native and reference engines.", + "runtimeName": "stats_smoke_test" + }, + { + "id": "rust:storage::bundle::test::make_bundle", + "source": "rust", + "file": "src/storage/bundle.rs", + "line": 171, + "name": "make_bundle", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::bundle::test::make_bundle" + }, + { + "id": "rust:storage::change::change_op_columns::tests::test_encode_decode_change_ops", + "source": "rust", + "file": "src/storage/change/change_op_columns.rs", + "line": 614, + "name": "test_encode_decode_change_ops", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::change::change_op_columns::tests::test_encode_decode_change_ops" + }, + { + "id": "rust:storage::columns::column_specification::tests::column_spec_encoding", + "source": "rust", + "file": "src/storage/columns/column_specification.rs", + "line": 220, + "name": "column_spec_encoding", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::columns::column_specification::tests::column_spec_encoding" + }, + { + "id": "rust:storage::parse::leb128::tests::leb_128_i64", + "source": "rust", + "file": "src/storage/parse/leb128.rs", + "line": 234, + "name": "leb_128_i64", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::parse::leb128::tests::leb_128_i64" + }, + { + "id": "rust:storage::parse::leb128::tests::leb_128_u32", + "source": "rust", + "file": "src/storage/parse/leb128.rs", + "line": 184, + "name": "leb_128_u32", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::parse::leb128::tests::leb_128_u32" + }, + { + "id": "rust:storage::parse::leb128::tests::leb_128_u64", + "source": "rust", + "file": "src/storage/parse/leb128.rs", + "line": 107, + "name": "leb_128_u64", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "storage::parse::leb128::tests::leb_128_u64" + }, + { + "id": "rust:sync::tests::both_peers_read_only", + "source": "rust", + "file": "src/sync.rs", + "line": 1455, + "name": "both_peers_read_only", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Two peers with independent changes enter read-only mode and exchange no document changes.", + "runtimeName": "sync::tests::both_peers_read_only" + }, + { + "id": "rust:sync::tests::both_peers_read_only_converges_to_none", + "source": "rust", + "file": "src/sync.rs", + "line": 1484, + "name": "both_peers_read_only_converges_to_none", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both read-only peers quiesce while retaining distinct local heads.", + "runtimeName": "sync::tests::both_peers_read_only_converges_to_none" + }, + { + "id": "rust:sync::tests::both_read_only_both_make_local_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 1553, + "name": "both_read_only_both_make_local_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both peers begin with independent local changes that remain isolated during read-only synchronization.", + "runtimeName": "sync::tests::both_read_only_both_make_local_changes" + }, + { + "id": "rust:sync::tests::both_read_only_one_makes_local_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 1510, + "name": "both_read_only_one_makes_local_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_BothReadOnlyOneMakesLocalChanges" + ], + "rationale": "Both peers are read-only; local changes on one are not transferred to the other and the session still quiesces.", + "runtimeName": "sync::tests::both_read_only_one_makes_local_changes" + }, + { + "id": "rust:sync::tests::both_read_only_simultaneous_changes_during_sync", + "source": "rust", + "file": "src/sync.rs", + "line": 1610, + "name": "both_read_only_simultaneous_changes_during_sync", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_BothReadOnlySimultaneousChanges" + ], + "rationale": "Both read-only peers make simultaneous changes across two rounds; neither receives the other's data and both quiesce.", + "runtimeName": "sync::tests::both_read_only_simultaneous_changes_during_sync" + }, + { + "id": "rust:sync::tests::both_toggle_after_multiple_read_only_rounds", + "source": "rust", + "file": "src/sync.rs", + "line": 2382, + "name": "both_toggle_after_multiple_read_only_rounds", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_BothToggleAfterMultipleReadOnlyRounds" + ], + "rationale": "Both peers accumulate changes across read-only rounds and exchange all of them after both switch to read-write.", + "runtimeName": "sync::tests::both_toggle_after_multiple_read_only_rounds" + }, + { + "id": "rust:sync::tests::both_toggle_read_only_to_read_write_simultaneously", + "source": "rust", + "file": "src/sync.rs", + "line": 2311, + "name": "both_toggle_read_only_to_read_write_simultaneously", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Both peers switch to read-write simultaneously and converge their prior local changes.", + "runtimeName": "sync::tests::both_toggle_read_only_to_read_write_simultaneously" + }, + { + "id": "rust:sync::tests::both_toggle_read_only_to_read_write_with_new_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 2344, + "name": "both_toggle_read_only_to_read_write_with_new_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_BothReadOnlyResumeConvergence" + ], + "rationale": "Independent changes created before the simultaneous transition are exchanged after reset.", + "runtimeName": "sync::tests::both_toggle_read_only_to_read_write_with_new_changes" + }, + { + "id": "rust:sync::tests::changes_not_sent_to_read_only_peer", + "source": "rust", + "file": "src/sync.rs", + "line": 2187, + "name": "changes_not_sent_to_read_only_peer", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "The publisher discovers read-only mode and quiesces without repeatedly sending document changes.", + "runtimeName": "sync::tests::changes_not_sent_to_read_only_peer" + }, + { + "id": "rust:sync::tests::encode_decode_empty_message", + "source": "rust", + "file": "src/sync.rs", + "line": 932, + "name": "encode_decode_empty_message", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncMessageEncodeDecodeEmptyV2" + ], + "rationale": "An empty V2 sync message encodes to the reference wire bytes (type byte then four zero collection counts) and parses back without error, keeping the native V2 codec byte-compatible.", + "runtimeName": "sync::tests::encode_decode_empty_message" + }, + { + "id": "rust:sync::tests::first_response_is_some_even_if_no_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 968, + "name": "first_response_is_some_even_if_no_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_FirstResponseIsSomeEvenIfNoChanges" + ], + "rationale": "Two peers at identical heads still exchange a first sync message so each learns the other's heads.", + "runtimeName": "sync::tests::first_response_is_some_even_if_no_changes" + }, + { + "id": "rust:sync::tests::generate_message_after_set_read_only_even_with_in_flight", + "source": "rust", + "file": "src/sync.rs", + "line": 2225, + "name": "generate_message_after_set_read_only_even_with_in_flight", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyModeOverridesInFlight" + ], + "rationale": "Changing to read-only forces a new message while an earlier message remains in flight.", + "runtimeName": "sync::tests::generate_message_after_set_read_only_even_with_in_flight" + }, + { + "id": "rust:sync::tests::generate_message_after_set_read_only_false_even_with_in_flight", + "source": "rust", + "file": "src/sync.rs", + "line": 2270, + "name": "generate_message_after_set_read_only_false_even_with_in_flight", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyModeOverridesInFlight" + ], + "rationale": "Changing back to read-write forces a reset message despite an in-flight message.", + "runtimeName": "sync::tests::generate_message_after_set_read_only_false_even_with_in_flight" + }, + { + "id": "rust:sync::tests::generate_sync_message_twice_does_nothing", + "source": "rust", + "file": "src/sync.rs", + "line": 958, + "name": "generate_sync_message_twice_does_nothing", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_SyncWaitsForPeerResponse" + ], + "rationale": "A second generation attempt while a message is in flight returns no message.", + "runtimeName": "sync::tests::generate_sync_message_twice_does_nothing" + }, + { + "id": "rust:sync::tests::if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc", + "source": "rust", + "file": "src/sync.rs", + "line": 1371, + "name": "if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_FirstMessageNoHeadsSendsWholeDoc" + ], + "rationale": "An empty peer receives the entire document in the first sync response and converges after a single exchange.", + "runtimeName": "sync::tests::if_first_message_has_no_heads_and_supports_v2_message_send_whole_doc" + }, + { + "id": "rust:sync::tests::in_flight_logic_should_not_sabotage_concurrent_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 1300, + "name": "in_flight_logic_should_not_sabotage_concurrent_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_ReferenceEditsWhileMessageInFlight" + ], + "rationale": "A reference peer commits another change while its previous message is awaiting acknowledgement.", + "runtimeName": "sync::tests::in_flight_logic_should_not_sabotage_concurrent_changes" + }, + { + "id": "rust:sync::tests::peer_discovers_remote_read_only_status", + "source": "rust", + "file": "src/sync.rs", + "line": 2150, + "name": "peer_discovers_remote_read_only_status", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "PeerReadOnly is asserted after flag exchange for native and Rust publishers.", + "runtimeName": "sync::tests::peer_discovers_remote_read_only_status" + }, + { + "id": "rust:sync::tests::read_only_empty_peer_syncs_with_data_peer", + "source": "rust", + "file": "src/sync.rs", + "line": 1432, + "name": "read_only_empty_peer_syncs_with_data_peer", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "An empty read-only peer exchanges protocol state with a populated peer without applying its document.", + "runtimeName": "sync::tests::read_only_empty_peer_syncs_with_data_peer" + }, + { + "id": "rust:sync::tests::read_only_fully_connected_triangle", + "source": "rust", + "file": "src/sync.rs", + "line": 1830, + "name": "read_only_fully_connected_triangle", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ReadOnlyFullyConnectedTriangle" + ], + "rationale": "A read-only peer publishes to two read-write peers that then merge; both gain all three change sets while the publisher keeps only its own.", + "runtimeName": "sync::tests::read_only_fully_connected_triangle" + }, + { + "id": "rust:sync::tests::read_only_peer_concurrent_changes_during_sync", + "source": "rust", + "file": "src/sync.rs", + "line": 1712, + "name": "read_only_peer_concurrent_changes_during_sync", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ReadOnlyPeerConcurrentChanges" + ], + "rationale": "A read-only peer that commits a change mid-flight still publishes it to the read-write peer, which the read-only peer never consumes in return.", + "runtimeName": "sync::tests::read_only_peer_concurrent_changes_during_sync" + }, + { + "id": "rust:sync::tests::read_only_peer_new_changes_between_sync_rounds", + "source": "rust", + "file": "src/sync.rs", + "line": 1651, + "name": "read_only_peer_new_changes_between_sync_rounds", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ReadOnlyPeerNewChangesBetweenRounds" + ], + "rationale": "A read-only publisher's new changes flow to the read-write peer as a conflict while the publisher never receives the consumer's data.", + "runtimeName": "sync::tests::read_only_peer_new_changes_between_sync_rounds" + }, + { + "id": "rust:sync::tests::read_only_peer_receives_same_changes_from_two_peers", + "source": "rust", + "file": "src/sync.rs", + "line": 1932, + "name": "read_only_peer_receives_same_changes_from_two_peers", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ReadOnlyPeerReceivesSameChangesFromTwoPeers" + ], + "rationale": "A read-only publisher announced the same changes by two peers keeps only its own history and later distributes a new change to both.", + "runtimeName": "sync::tests::read_only_peer_receives_same_changes_from_two_peers" + }, + { + "id": "rust:sync::tests::read_only_publisher_to_multiple_consumers", + "source": "rust", + "file": "src/sync.rs", + "line": 1748, + "name": "read_only_publisher_to_multiple_consumers", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ReadOnlyPublisherToMultipleConsumers" + ], + "rationale": "A read-only publisher's changes reach two independent consumers, and one consumer's changes never reach the other through the publisher.", + "runtimeName": "sync::tests::read_only_publisher_to_multiple_consumers" + }, + { + "id": "rust:sync::tests::read_only_sync_does_not_apply_incoming_changes", + "source": "rust", + "file": "src/sync.rs", + "line": 1398, + "name": "read_only_sync_does_not_apply_incoming_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "Native and Rust read-only receivers reject incoming changes in both engine directions.", + "runtimeName": "sync::tests::read_only_sync_does_not_apply_incoming_changes" + }, + { + "id": "rust:sync::tests::should_allow_simultaneous_messages_during_synchronisation", + "source": "rust", + "file": "src/sync.rs", + "line": 1017, + "name": "should_allow_simultaneous_messages_during_synchronisation", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_AllowSimultaneousMessages" + ], + "rationale": "Two peers each committing five concurrent changes exchange messages in both directions and converge to identical heads with each other's keys.", + "runtimeName": "sync::tests::should_allow_simultaneous_messages_during_synchronisation" + }, + { + "id": "rust:sync::tests::should_handle_chains_of_false_positives", + "source": "rust", + "file": "src/sync.rs", + "line": 1190, + "name": "should_handle_chains_of_false_positives", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ShouldHandleChainsOfFalsePositives" + ], + "rationale": "Two chained changes that are both Bloom false positives (located with the reference engine's real Bloom filter) still converge under V2 sync on both engines.", + "runtimeName": "sync::tests::should_handle_chains_of_false_positives" + }, + { + "id": "rust:sync::tests::should_handle_false_positive_head", + "source": "rust", + "file": "src/sync.rs", + "line": 1134, + "name": "should_handle_false_positive_head", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ShouldHandleFalsePositiveHead" + ], + "rationale": "A concurrent head that is a Bloom false positive (located with the reference engine's real Bloom filter) still converges under V2 sync on both engines.", + "runtimeName": "sync::tests::should_handle_false_positive_head" + }, + { + "id": "rust:sync::tests::should_handle_lots_of_branching_and_merging", + "source": "rust", + "file": "src/sync.rs", + "line": 1255, + "name": "should_handle_lots_of_branching_and_merging", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_BranchingAndMerging" + ], + "rationale": "Two peers exchange many concurrent changes, a third peer's concurrent change is merged into one, and a final synchronization converges both peers to identical heads on both engines.", + "runtimeName": "sync::tests::should_handle_lots_of_branching_and_merging" + }, + { + "id": "rust:sync::tests::should_not_reply_if_we_have_no_data_after_first_round", + "source": "rust", + "file": "src/sync.rs", + "line": 993, + "name": "should_not_reply_if_we_have_no_data_after_first_round", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ShouldNotReplyIfNoDataAfterFirstRound" + ], + "rationale": "Two empty peers exchange a mandatory first message each, then fall silent once neither has anything to send.", + "runtimeName": "sync::tests::should_not_reply_if_we_have_no_data_after_first_round" + }, + { + "id": "rust:sync::tests::stale_shared_heads_after_read_only_sync", + "source": "rust", + "file": "src/sync.rs", + "line": 1885, + "name": "stale_shared_heads_after_read_only_sync", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_StaleSharedHeadsAfterReadOnlySync" + ], + "rationale": "A consumer that already has the read-only publisher's changes via a third peer re-syncs directly and still quiesces without the publisher accepting data.", + "runtimeName": "sync::tests::stale_shared_heads_after_read_only_sync" + }, + { + "id": "rust:sync::tests::switch_read_only_to_read_write_mid_session", + "source": "rust", + "file": "src/sync.rs", + "line": 1988, + "name": "switch_read_only_to_read_write_mid_session", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ReadOnlyParity" + ], + "rationale": "A read-only peer switches to read-write, requests a reset, receives prior changes, and converges.", + "runtimeName": "sync::tests::switch_read_only_to_read_write_mid_session" + }, + { + "id": "rust:sync::tests::switch_read_only_to_read_write_with_multiple_rounds", + "source": "rust", + "file": "src/sync.rs", + "line": 2060, + "name": "switch_read_only_to_read_write_with_multiple_rounds", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_SwitchReadOnlyToReadWriteMultipleRounds" + ], + "rationale": "A read-only peer that ignored several rounds of changes receives all of them after switching to read-write and converges.", + "runtimeName": "sync::tests::switch_read_only_to_read_write_with_multiple_rounds" + }, + { + "id": "rust:sync::tests::switch_read_write_to_read_only_mid_session", + "source": "rust", + "file": "src/sync.rs", + "line": 2021, + "name": "switch_read_write_to_read_only_mid_session", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_SwitchReadWriteToReadOnlyMidSession" + ], + "rationale": "A peer switched to read-only mid-session still publishes its own new change but no longer accepts the peer's new change.", + "runtimeName": "sync::tests::switch_read_write_to_read_only_mid_session" + }, + { + "id": "rust:sync::tests::switch_to_read_write_with_old_peer", + "source": "rust", + "file": "src/sync.rs", + "line": 2440, + "name": "switch_to_read_write_with_old_peer", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Legacy V1 sync protocol interoperability (V1<->V2 sessions, compressed changes in V1 sessions, and old-peer capability fallback). Out of scope: this project uses only the V2 sync protocol.", + "runtimeName": "sync::tests::switch_to_read_write_with_old_peer" + }, + { + "id": "rust:sync::tests::toggle_read_only_multiple_times", + "source": "rust", + "file": "src/sync.rs", + "line": 2104, + "name": "toggle_read_only_multiple_times", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustSync_ToggleReadOnlyMultipleTimes" + ], + "rationale": "Toggling read-only on and off across rounds gates change acceptance correctly and converges once read-write.", + "runtimeName": "sync::tests::toggle_read_only_multiple_times" + }, + { + "id": "rust:sync::tests::triangle_changes_arrive_via_two_paths", + "source": "rust", + "file": "src/sync.rs", + "line": 1784, + "name": "triangle_changes_arrive_via_two_paths", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestSyncState_ThreePeerRelayConvergesWithReference" + ], + "rationale": "Three peers relay concurrent changes through native and reference engines and converge to identical heads.", + "runtimeName": "sync::tests::triangle_changes_arrive_via_two_paths" + }, + { + "id": "rust:sync::v1_compat_test::sync_from_v1_to_v2", + "source": "rust", + "file": "src/sync/v1_compat_test/mod.rs", + "line": 473, + "name": "sync_from_v1_to_v2", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Legacy V1 sync protocol interoperability (V1<->V2 sessions, compressed changes in V1 sessions, and old-peer capability fallback). Out of scope: this project uses only the V2 sync protocol.", + "runtimeName": "sync::v1_compat_test::sync_from_v1_to_v2" + }, + { + "id": "rust:sync::v1_compat_test::sync_from_v2_to_v1", + "source": "rust", + "file": "src/sync/v1_compat_test/mod.rs", + "line": 496, + "name": "sync_from_v2_to_v1", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Legacy V1 sync protocol interoperability (V1<->V2 sessions, compressed changes in V1 sessions, and old-peer capability fallback). Out of scope: this project uses only the V2 sync protocol.", + "runtimeName": "sync::v1_compat_test::sync_from_v2_to_v1" + }, + { + "id": "rust:sync::v1_compat_test::sync_v1_to_v2_with_compressed_change", + "source": "rust", + "file": "src/sync/v1_compat_test/mod.rs", + "line": 519, + "name": "sync_v1_to_v2_with_compressed_change", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Legacy V1 sync protocol interoperability (V1<->V2 sessions, compressed changes in V1 sessions, and old-peer capability fallback). Out of scope: this project uses only the V2 sync protocol.", + "runtimeName": "sync::v1_compat_test::sync_v1_to_v2_with_compressed_change" + }, + { + "id": "rust:test_change_encoding_expanded_change_round_trip", + "source": "rust", + "file": "tests/test.rs", + "line": 1336, + "name": "test_change_encoding_expanded_change_round_trip", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestChangeEncodingExpandedRoundTrip" + ], + "rationale": "A change decoded from its canonical bytes re-encodes to exactly those bytes, validating change wire fidelity.", + "runtimeName": "test_change_encoding_expanded_change_round_trip" + }, + { + "id": "rust:test_compressed_changes", + "source": "rust", + "file": "tests/test.rs", + "line": 1296, + "name": "test_compressed_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDecode_CompressedOfficialChangeFixture" + ], + "rationale": "An official change is compressed, decoded, and verified to preserve its original hash and change type.", + "runtimeName": "test_compressed_changes" + }, + { + "id": "rust:test_compressed_doc_cols", + "source": "rust", + "file": "tests/test.rs", + "line": 1312, + "name": "test_compressed_doc_cols", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTest_CompressedDocCols" + ], + "rationale": "A large document saves smaller with DEFLATE compression than without on both engines (native compresses change chunks above the reference DEFLATE_MIN_SIZE), and the compressed save loads back to the same 200-element list.", + "runtimeName": "test_compressed_doc_cols" + }, + { + "id": "rust:test_does_not_add_size_when_strings_are_not_converted", + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "line": 61, + "name": "test_does_not_add_size_when_strings_are_not_converted", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustConvert_DoesNotAddSizeWhenStringsAreNotConverted" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference.", + "runtimeName": "test_does_not_add_size_when_strings_are_not_converted" + }, + { + "id": "rust:test_get_change_meta", + "source": "rust", + "file": "tests/test.rs", + "line": 1927, + "name": "test_get_change_meta", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeParsesJavaScriptChange" + ], + "rationale": "The native decoder exposes and verifies the official change metadata fields and hash.", + "runtimeName": "test_get_change_meta" + }, + { + "id": "rust:test_get_last_local_change_generation", + "source": "rust", + "file": "tests/test.rs", + "line": 2460, + "name": "test_get_last_local_change_generation", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_JavaScriptPreservesGoChanges", + "TestDocument_AppliesDependentChangesInAnyOrder" + ], + "rationale": "The most recent generated change is returned with its exact hash and bytes and preserved by JavaScript.", + "runtimeName": "test_get_last_local_change_generation" + }, + { + "id": "rust:test_load_incremental_partial_load", + "source": "rust", + "file": "tests/test.rs", + "line": 1903, + "name": "test_load_incremental_partial_load", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_IncrementalSaveLoadParity" + ], + "rationale": "A standalone incremental change batch applies to a peer in both Go-to-Rust and Rust-to-Go directions.", + "runtimeName": "test_load_incremental_partial_load" + }, + { + "id": "rust:test_local_inc_in_map", + "source": "rust", + "file": "tests/test.rs", + "line": 1149, + "name": "test_local_inc_in_map", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_CountersMatchReference" + ], + "rationale": "Map counters are incremented repeatedly with positive and negative deltas in native and Rust engines.", + "runtimeName": "test_local_inc_in_map" + }, + { + "id": "rust:test_mark_behavior_on_delete_insert", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 464, + "name": "test_mark_behavior_on_delete_insert", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_MarkBehaviorOnDeleteInsert" + ], + "rationale": "Deleting all marked text and inserting new text leaves the new text unmarked on both engines.", + "runtimeName": "test_mark_behavior_on_delete_insert" + }, + { + "id": "rust:test_merging_test_conflicts_then_saving_and_loading", + "source": "rust", + "file": "tests/test.rs", + "line": 1194, + "name": "test_merging_test_conflicts_then_saving_and_loading", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "Concurrent map assignments are merged, all conflicts and the winner are checked, then the merged document is loaded by Rust.", + "runtimeName": "test_merging_test_conflicts_then_saving_and_loading" + }, + { + "id": "rust:test_overwriting_a_conflict", + "source": "rust", + "file": "tests/test.rs", + "line": 2481, + "name": "test_overwriting_a_conflict", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestDocument_ConcurrentStringWinnerMatchesReference" + ], + "rationale": "The winner and both conflicts are checked before a new assignment clears the conflict.", + "runtimeName": "test_overwriting_a_conflict" + }, + { + "id": "rust:test_remote_patches_for_marks_with_expand_after", + "source": "rust", + "file": "tests/text.rs", + "line": 615, + "name": "test_remote_patches_for_marks_with_expand_after", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_RemotePatchesForExpandAfter" + ], + "rationale": "A remote insertion at an after-expanding boundary produces the same marked splice patch as the local edit.", + "runtimeName": "test_remote_patches_for_marks_with_expand_after" + }, + { + "id": "rust:test_splice_with_mark", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 816, + "name": "test_splice_with_mark", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_SpliceWithMark" + ], + "rationale": "Replacing text exactly at two mark boundaries preserves the expanding mark while dropping the non-expanding mark, matching upstream issue #935.", + "runtimeName": "test_splice_with_mark" + }, + { + "id": "rust:test_strings_in_lists_are_converted_to_text", + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "line": 34, + "name": "test_strings_in_lists_are_converted_to_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustConvert_StringsInListsAreConvertedToText" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference.", + "runtimeName": "test_strings_in_lists_are_converted_to_text" + }, + { + "id": "rust:test_strings_in_maps_are_converted_to_text", + "source": "rust", + "file": "tests/convert_string_to_text.rs", + "line": 8, + "name": "test_strings_in_maps_are_converted_to_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustConvert_StringsInMapsAreConvertedToText" + ], + "rationale": "Loading with the string-to-text migration converts string scalars in maps and lists into text objects, matching the reference.", + "runtimeName": "test_strings_in_maps_are_converted_to_text" + }, + { + "id": "rust:text_complex_block_properties", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 326, + "name": "text_complex_block_properties", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustRichText_ComplexBlockProperties" + ], + "rationale": "A block populated with nested text and list properties materializes identical span block values on both engines.", + "runtimeName": "text_complex_block_properties" + }, + { + "id": "rust:text_diff::myers::test_find_middle_snake", + "source": "rust", + "file": "src/text_diff/myers.rs", + "line": 322, + "name": "test_find_middle_snake", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "text_diff::myers::test_find_middle_snake" + }, + { + "id": "rust:text_diff::utils::test_common_prefix_len", + "source": "rust", + "file": "src/text_diff/utils.rs", + "line": 82, + "name": "test_common_prefix_len", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "text_diff::utils::test_common_prefix_len" + }, + { + "id": "rust:text_diff::utils::test_common_suffix_len", + "source": "rust", + "file": "src/text_diff/utils.rs", + "line": 102, + "name": "test_common_suffix_len", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "text_diff::utils::test_common_suffix_len" + }, + { + "id": "rust:transaction_at_with_patch_log_from_another_document_does_not_panic", + "source": "rust", + "file": "tests/test.rs", + "line": 60, + "name": "transaction_at_with_patch_log_from_another_document_does_not_panic", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust API safety guard: using a PatchLog that belongs to another document returns an error instead of panicking. A Rust-binding safety contract with no cross-engine wire or state meaning.", + "runtimeName": "transaction_at_with_patch_log_from_another_document_does_not_panic" + }, + { + "id": "rust:transaction_with_patch_log_from_another_document_does_not_panic", + "source": "rust", + "file": "tests/test.rs", + "line": 50, + "name": "transaction_with_patch_log_from_another_document_does_not_panic", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Rust API safety guard: using a PatchLog that belongs to another document returns an error instead of panicking. A Rust-binding safety contract with no cross-engine wire or state meaning.", + "runtimeName": "transaction_with_patch_log_from_another_document_does_not_panic" + }, + { + "id": "rust:transaction::inner::tests::map_rollback_doesnt_panic", + "source": "rust", + "file": "src/transaction/inner.rs", + "line": 1395, + "name": "map_rollback_doesnt_panic", + "classification": "language-specific", + "requirement": "language-specific", + "localTests": [], + "rationale": "Exercises a private Rust data structure or algorithm rather than observable Automerge behavior.", + "runtimeName": "transaction::inner::tests::map_rollback_doesnt_panic" + }, + { + "id": "rust:transaction::owned_transaction::tests::commit_with_options", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 157, + "name": "commit_with_options", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::commit_with_options" + }, + { + "id": "rust:transaction::owned_transaction::tests::empty_commit_returns_none_hash", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 261, + "name": "empty_commit_returns_none_hash", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::empty_commit_returns_none_hash" + }, + { + "id": "rust:transaction::owned_transaction::tests::get_heads_returns_pre_tx_heads", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 236, + "name": "get_heads_returns_pre_tx_heads", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::get_heads_returns_pre_tx_heads" + }, + { + "id": "rust:transaction::owned_transaction::tests::log_patches", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 223, + "name": "log_patches", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::log_patches" + }, + { + "id": "rust:transaction::owned_transaction::tests::nested_objects", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 145, + "name": "nested_objects", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::nested_objects" + }, + { + "id": "rust:transaction::owned_transaction::tests::owned_transaction_at", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 196, + "name": "owned_transaction_at", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::owned_transaction_at" + }, + { + "id": "rust:transaction::owned_transaction::tests::pending_ops", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 249, + "name": "pending_ops", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::pending_ops" + }, + { + "id": "rust:transaction::owned_transaction::tests::put_and_get_roundtrip", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 121, + "name": "put_and_get_roundtrip", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::put_and_get_roundtrip" + }, + { + "id": "rust:transaction::owned_transaction::tests::read_during_transaction", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 134, + "name": "read_during_transaction", + "classification": "pending", + "requirement": "api-convenience", + "localTests": [], + "rationale": "Exercises the Rust owned-transaction object API (into_transaction, pending_ops, commit/rollback tuples); the underlying state behavior is covered by the core-model, metadata, and current-state parity suites.", + "runtimeName": "transaction::owned_transaction::tests::read_during_transaction" + }, + { + "id": "rust:transaction::owned_transaction::tests::rollback_discards_ops", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 168, + "name": "rollback_discards_ops", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTransaction_RollbackDiscardsOps" + ], + "rationale": "Transaction rollback discards uncommitted operations and reports the discarded count, matching the reference.", + "runtimeName": "transaction::owned_transaction::tests::rollback_discards_ops" + }, + { + "id": "rust:transaction::owned_transaction::tests::rollback_undoes_writes", + "source": "rust", + "file": "src/transaction/owned_transaction.rs", + "line": 186, + "name": "rollback_undoes_writes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustTransaction_RollbackUndoesWrites" + ], + "rationale": "Transaction rollback discards uncommitted operations and reports the discarded count, matching the reference.", + "runtimeName": "transaction::owned_transaction::tests::rollback_undoes_writes" + }, + { + "id": "rust:unmark", + "source": "rust", + "file": "tests/text_encoding.rs", + "line": 250, + "name": "unmark", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestPureGoDocument_MarkAuthoringMatchesReference" + ], + "rationale": "Go and Rust independently unmark an interior UTF-16 range and cross-load the split spans.", + "runtimeName": "unmark" + }, + { + "id": "rust:unmark_creates_gaps", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1045, + "name": "unmark_creates_gaps", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "Null mark operations remove the mark from the requested interior range without affecting adjacent spans.", + "runtimeName": "unmark_creates_gaps" + }, + { + "id": "rust:unmark_part_of_range", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1007, + "name": "unmark_part_of_range", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestConformance_NativeSplitMarks" + ], + "rationale": "Unmarking the middle of a marked range produces two marked outer spans and an unmarked gap.", + "runtimeName": "unmark_part_of_range" + }, + { + "id": "rust:update_blocks_change_block_properties", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 22, + "name": "update_blocks_change_block_properties", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_blocks_change_block_properties" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_blocks_change_block_properties" + }, + { + "id": "rust:update_blocks_noop", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 234, + "name": "update_blocks_noop", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans_Noop" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_blocks_noop" + }, + { + "id": "rust:update_blocks_updates_marks", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 171, + "name": "update_blocks_updates_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_blocks_updates_marks" + }, + { + "id": "rust:update_blocks_updates_text", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 107, + "name": "update_blocks_updates_text", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_text" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_blocks_updates_text" + }, + { + "id": "rust:update_blocks_updates_text_and_blocks_at_once", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 274, + "name": "update_blocks_updates_text_and_blocks_at_once", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_blocks_updates_text_and_blocks_at_once" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_blocks_updates_text_and_blocks_at_once" + }, + { + "id": "rust:update_spans_delete_attribute", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 358, + "name": "update_spans_delete_attribute", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_spans_delete_attribute" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_spans_delete_attribute" + }, + { + "id": "rust:update_spans_diffs_marks", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 621, + "name": "update_spans_diffs_marks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_spans_diffs_marks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_spans_diffs_marks" + }, + { + "id": "rust:update_spans_uses_expand_config", + "source": "rust", + "file": "tests/block_tests.rs", + "line": 676, + "name": "update_spans_uses_expand_config", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_spans_uses_expand_config" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_spans_uses_expand_config" + }, + { + "id": "rust:update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1473, + "name": "update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustDiffMarks/update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes" + ], + "rationale": "update_spans reconciles text and marks to the given spans, matching the reference materialization.", + "runtimeName": "update_spans_which_inserts_at_the_end_of_expand_mark_doesnt_generate_mark_changes" + }, + { + "id": "rust:update_spans_with_only_blocks", + "source": "rust", + "file": "tests/diff_marks.rs", + "line": 1398, + "name": "update_spans_with_only_blocks", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustBlockSpans/update_spans_with_only_blocks" + ], + "rationale": "update_spans reconciles text, marks, and block markers to the given spans, matching the reference materialization.", + "runtimeName": "update_spans_with_only_blocks" + }, + { + "id": "rust:update_text_big_ole_graphemes", + "source": "rust", + "file": "tests/text.rs", + "line": 33, + "name": "update_text_big_ole_graphemes", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_UpdateTextBigOleGraphemes" + ], + "rationale": "update_text treats emoji ZWJ sequences as single grapheme clusters so concurrent family swaps merge side by side, matching the reference change history.", + "runtimeName": "update_text_big_ole_graphemes" + }, + { + "id": "rust:update_text_change_at", + "source": "rust", + "file": "tests/text.rs", + "line": 645, + "name": "update_text_change_at", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestRustText_UpdateTextChangeAt" + ], + "rationale": "An isolated update_text branches from the initial heads and integrates alongside the concurrent update, yielding the reference text on both engines.", + "runtimeName": "update_text_change_at" + }, + { + "id": "rust:zero_length_data", + "source": "rust", + "file": "tests/test.rs", + "line": 2409, + "name": "zero_length_data", + "classification": "covered", + "requirement": "interop-required", + "localTests": [ + "TestLoad_InvalidDocument", + "FuzzDecode" + ], + "rationale": "Empty input is a deterministic loader regression seed and part of the native decoder fuzz corpus.", + "runtimeName": "zero_length_data" + } + ] +} diff --git a/pkg/automerge/text_encoding_parity_test.go b/pkg/automerge/text_encoding_parity_test.go new file mode 100644 index 0000000000..35faa12050 --- /dev/null +++ b/pkg/automerge/text_encoding_parity_test.go @@ -0,0 +1,716 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust text-encoding tests from +// automerge 0.10 (rust/automerge/tests/text_encoding.rs). The reference backend +// is built with the utf16-indexing feature, so every text index is expressed in +// UTF-16 code units. Each scenario runs identically on the native Go engine and +// the Rust/WASM reference engine and asserts their results agree with the +// documented UTF-16 expectation. The πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦ family emoji used throughout is a +// single grapheme cluster spanning 7 code points and 11 UTF-16 code units. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +const familyEmoji = "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦" + +// seedText creates a text object seeded with the given content and returns the +// committed document together with map- and text-typed handles to it. +func seedText( + t *testing.T, + ctx context.Context, + engine rustParityEngine, + content string, +) (*automerge.Document, *automerge.Object, *automerge.Text) { + t.Helper() + + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, content)) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + object, err := document.Root().Object(ctx, "text") + require.NoError(t, err) + + return document, object, text +} + +// TestRustTextEncoding_Length reproduces the utf16 case of length. +func TestRustTextEncoding_Length(t *testing.T) { + t.Parallel() + + ctx := context.Background() + lengths := make(map[string]uint64) + + for _, engine := range rustParityEngines() { + _, object, _ := seedText(t, ctx, engine, "hello"+familyEmoji) + + length, err := object.Len(ctx) + require.NoError(t, err) + lengths[engine.name] = length + } + + assert.Equal(t, uint64(16), lengths["reference"]) + assert.Equal(t, lengths["reference"], lengths["native"]) +} + +// TestRustTextEncoding_SpliceText reproduces the utf16 case of splice_text. +func TestRustTextEncoding_SpliceText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "hello "+familyEmoji+" world") + require.NoError(t, text.Splice(ctx, 18, 0, "beautiful ")) + _, err := document.Commit(ctx, "splice", commitTime) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + results[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "hello "+familyEmoji+" beautiful world", results["reference"]) + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustTextEncoding_Get reproduces the utf16 case of get. +func TestRustTextEncoding_Get(t *testing.T) { + t.Parallel() + + ctx := context.Background() + values := make(map[string]string) + + for _, engine := range rustParityEngines() { + _, object, _ := seedText(t, ctx, engine, "he"+familyEmoji+"lo") + + scalar, err := object.ScalarAt(ctx, 13) + require.NoError(t, err) + require.Equal(t, automerge.ScalarTypeString, scalar.Type) + values[engine.name] = scalar.String + } + + assert.Equal(t, "l", values["reference"]) + assert.Equal(t, values["reference"], values["native"]) +} + +// TestRustTextEncoding_Put reproduces the utf16 case of put. +func TestRustTextEncoding_Put(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, object, text := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + require.NoError(t, object.PutScalarAt( + ctx, + 13, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}, + )) + _, err := document.Commit(ctx, "put", commitTime) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + results[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "he"+familyEmoji+"Llo", results["reference"]) + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustTextEncoding_Insert reproduces the utf16 case of insert. +func TestRustTextEncoding_Insert(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, object, text := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + require.NoError(t, object.InsertScalar( + ctx, + 13, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}, + )) + _, err := document.Commit(ctx, "insert", commitTime) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + results[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "he"+familyEmoji+"Lllo", results["reference"]) + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustTextEncoding_Delete reproduces the utf16 case of delete. +func TestRustTextEncoding_Delete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, object, text := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + require.NoError(t, object.DeleteIndex(ctx, 13)) + _, err := document.Commit(ctx, "delete", commitTime) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + results[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "he"+familyEmoji+"lo", results["reference"]) + assert.Equal(t, results["reference"], results["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +} + +// diffTextPatches seeds a text object, runs the mutation, and returns the diff +// between the states before and after the mutation for each engine. +func diffTextPatches( + t *testing.T, + ctx context.Context, + content string, + mutate func(ctx context.Context, object *automerge.Object, text *automerge.Text) error, +) map[string][]automerge.Patch { + t.Helper() + + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, object, text := seedText(t, ctx, engine, content) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, mutate(ctx, object, text)) + after, err := document.Commit(ctx, "mutate", commitTime) + require.NoError(t, err) + + patches, err := document.Diff(ctx, before, []automerge.Hash{after}) + require.NoError(t, err) + result[engine.name] = patches + } + + return result +} + +// TestRustTextEncoding_PatchInsert reproduces the utf16 case of patch_insert: +// an insert produces a SpliceText patch addressed by UTF-16 code units. +func TestRustTextEncoding_PatchInsert(t *testing.T) { + t.Parallel() + + ctx := context.Background() + patches := diffTextPatches( + t, + ctx, + "he"+familyEmoji+"llo", + func(ctx context.Context, object *automerge.Object, _ *automerge.Text) error { + return object.InsertScalar( + ctx, + 13, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}, + ) + }, + ) + + require.Len(t, patches["reference"], 1) + assert.Equal(t, automerge.PatchSpliceText, patches["reference"][0].Action) + assert.Equal(t, uint64(13), patches["reference"][0].Index) + assert.Equal(t, "L", patches["reference"][0].Text) + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustTextEncoding_PatchSpliceText reproduces the utf16 case of +// patch_splice_text: a splice produces a SpliceText patch at a UTF-16 index. +func TestRustTextEncoding_PatchSpliceText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + patches := diffTextPatches( + t, + ctx, + "he"+familyEmoji+"llo", + func(ctx context.Context, _ *automerge.Object, text *automerge.Text) error { + return text.Splice(ctx, 13, 0, "L") + }, + ) + + require.Len(t, patches["reference"], 1) + assert.Equal(t, automerge.PatchSpliceText, patches["reference"][0].Action) + assert.Equal(t, uint64(13), patches["reference"][0].Index) + assert.Equal(t, "L", patches["reference"][0].Text) + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustTextEncoding_PatchDelete reproduces the utf16 case of patch_delete: +// a delete produces a DeleteSeq patch at a UTF-16 index with length one. +func TestRustTextEncoding_PatchDelete(t *testing.T) { + t.Parallel() + + ctx := context.Background() + patches := diffTextPatches( + t, + ctx, + "he"+familyEmoji+"llo", + func(ctx context.Context, object *automerge.Object, _ *automerge.Text) error { + return object.DeleteIndex(ctx, 13) + }, + ) + + require.Len(t, patches["reference"], 1) + assert.Equal(t, automerge.PatchDeleteSeq, patches["reference"][0].Action) + assert.Equal(t, uint64(13), patches["reference"][0].Index) + assert.Equal(t, uint64(1), patches["reference"][0].Length) + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestRustText_IncrementalSplicePatchesIncludeMarks reproduces +// incremental_splice_patches_include_marks: text spliced inside an expanding +// mark is reported as a splice_text patch carrying that mark, with no separate +// mark patch for the range growth. +func TestRustText_IncrementalSplicePatchesIncludeMarks(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "12345") + require.NoError(t, text.Mark( + ctx, 1, 2, "strong", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + _, err := document.Commit(ctx, "mark", commitTime) + require.NoError(t, err) + require.NoError(t, document.UpdateDiffCursor(ctx)) + + var patches []automerge.Patch + + require.NoError(t, text.Splice(ctx, 1, 0, "-")) + _, err = document.Commit(ctx, "s1", commitTime) + require.NoError(t, err) + first, err := document.DiffIncremental(ctx) + require.NoError(t, err) + patches = append(patches, first...) + + require.NoError(t, text.Splice(ctx, 2, 0, "-")) + _, err = document.Commit(ctx, "s2", commitTime) + require.NoError(t, err) + second, err := document.DiffIncremental(ctx) + require.NoError(t, err) + patches = append(patches, second...) + + result[engine.name] = patches + } + + require.Len(t, result["reference"], 2) + + for _, patch := range result["reference"] { + assert.Equal(t, automerge.PatchSpliceText, patch.Action) + require.Len(t, patch.Marks, 1) + assert.Equal(t, "strong", patch.Marks[0].Name) + } + + assert.Equal(t, uint64(1), result["reference"][0].Index) + assert.Equal(t, uint64(2), result["reference"][1].Index) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustText_NoexpandMarksAtEndOfText reproduces +// noexpand_marks_at_the_end_of_text_should_not_emit_marked_patches_on_following_insertions: +// text appended after a non-expanding mark does not inherit it, so the splice +// patch carries no marks. +func TestRustText_NoexpandMarksAtEndOfText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "Hello world") + require.NoError(t, text.Mark( + ctx, 10, 11, "strong", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandNone, + )) + _, err := document.Commit(ctx, "mark", commitTime) + require.NoError(t, err) + require.NoError(t, document.UpdateDiffCursor(ctx)) + + require.NoError(t, text.Splice(ctx, 11, 0, "a")) + _, err = document.Commit(ctx, "append", commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + require.Len(t, result["reference"], 1) + assert.Equal(t, automerge.PatchSpliceText, result["reference"][0].Action) + assert.Empty(t, result["reference"][0].Marks) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustText_LocalPatchesCreatedForMarks reproduces local_patches_created_for_marks: +// materializing marked text through the diff cursor splits it into one +// splice_text patch per mark run, each carrying the marks active on that run. +func TestRustText_LocalPatchesCreatedForMarks(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "the quick fox jumps over the lazy dog")) + require.NoError(t, text.Mark( + ctx, 0, 37, "bold", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + require.NoError(t, text.Mark( + ctx, 4, 19, "italic", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + require.NoError(t, text.Mark( + ctx, 10, 13, "comment:somerandomcommentid", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "foxes are my favorite animal!"}, + automerge.MarkExpandBoth, + )) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + reference := result["reference"] + require.NotEmpty(t, reference) + assert.Equal(t, automerge.PatchPutMap, reference[0].Action) + + runs := reference[1:] + require.Len(t, runs, 5) + + expected := []struct { + text string + names []string + }{ + {"the ", []string{"bold"}}, + {"quick ", []string{"bold", "italic"}}, + {"fox", []string{"bold", "comment:somerandomcommentid", "italic"}}, + {" jumps", []string{"bold", "italic"}}, + {" over the lazy dog", []string{"bold"}}, + } + + for index, want := range expected { + assert.Equal(t, automerge.PatchSpliceText, runs[index].Action) + assert.Equal(t, want.text, runs[index].Text) + + names := make([]string, 0, len(runs[index].Marks)) + for _, mark := range runs[index].Marks { + names = append(names, mark.Name) + } + + assert.Equal(t, want.names, names) + } + + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustTextEncoding_PatchPutSeq reproduces the utf16 case of patch_put_seq: +// an in-place text put reported through the incremental diff cursor produces a +// PutSeq patch addressed by UTF-16 code units. +func TestRustTextEncoding_PatchPutSeq(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, object, _ := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, object.PutScalarAt( + ctx, + 13, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}, + )) + _, err := document.Commit(ctx, "put", commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + require.Len(t, result["reference"], 1) + assert.Equal(t, automerge.PatchPutSeq, result["reference"][0].Action) + assert.Equal(t, uint64(13), result["reference"][0].Index) + require.NotNil(t, result["reference"][0].Value.Scalar) + assert.Equal(t, "L", result["reference"][0].Value.Scalar.String) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestDocument_IncrementalDiffMatchesReference exercises the incremental diff +// cursor across map, list, and text mutations and asserts the native and +// reference patch streams agree for each committed change. +func TestDocument_IncrementalDiffMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + scenarios := []struct { + name string + mutate func(ctx context.Context, object *automerge.Object, text *automerge.Text) error + }{ + {"text_put", func(ctx context.Context, object *automerge.Object, _ *automerge.Text) error { + return object.PutScalarAt(ctx, 13, automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}) + }}, + {"text_insert", func(ctx context.Context, object *automerge.Object, _ *automerge.Text) error { + return object.InsertScalar(ctx, 13, automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"}) + }}, + {"text_splice", func(ctx context.Context, _ *automerge.Object, text *automerge.Text) error { + return text.Splice(ctx, 13, 0, "AB") + }}, + {"text_delete", func(ctx context.Context, object *automerge.Object, _ *automerge.Text) error { + return object.DeleteIndex(ctx, 13) + }}, + {"text_mark", func(ctx context.Context, _ *automerge.Object, text *automerge.Text) error { + return text.Mark( + ctx, + 1, + 13, + "bold", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + ) + }}, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, object, text := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + + require.NoError(t, document.UpdateDiffCursor(ctx)) + require.NoError(t, scenario.mutate(ctx, object, text)) + _, err := document.Commit(ctx, scenario.name, commitTime) + require.NoError(t, err) + + patches, err := document.DiffIncremental(ctx) + require.NoError(t, err) + result[engine.name] = patches + } + + assert.NotEmpty(t, result["reference"]) + assert.Equal(t, result["reference"], result["native"]) + }) + } +} + +// TestRustTextEncoding_PatchMark reproduces the utf16 case of patch_mark: a +// mark produces a Mark patch whose start and end are UTF-16 code units. +func TestRustTextEncoding_PatchMark(t *testing.T) { + t.Parallel() + + ctx := context.Background() + patches := diffTextPatches( + t, + ctx, + "he"+familyEmoji+"llo", + func(ctx context.Context, _ *automerge.Object, text *automerge.Text) error { + return text.Mark( + ctx, + 1, + 13, + "bold", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + ) + }, + ) + + require.Len(t, patches["reference"], 1) + assert.Equal(t, automerge.PatchMark, patches["reference"][0].Action) + require.Len(t, patches["reference"][0].Marks, 1) + assert.Equal(t, uint32(1), patches["reference"][0].Marks[0].Start) + assert.Equal(t, uint32(13), patches["reference"][0].Marks[0].End) + assert.Equal(t, "bold", patches["reference"][0].Marks[0].Name) + assert.Equal(t, patches["reference"], patches["native"]) +} + +// TestTextDiff_MarkRemovalMatchesReference verifies that removing a mark emits a +// mark patch carrying a null value on both engines. +func TestTextDiff_MarkRemovalMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "hello world") + require.NoError(t, text.Mark( + ctx, + 0, + 5, + "bold", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + _, err := document.Commit(ctx, "mark", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, text.Unmark(ctx, 0, 5, "bold", automerge.MarkExpandBoth)) + after, err := document.Commit(ctx, "unmark", commitTime) + require.NoError(t, err) + + patches, err := document.Diff(ctx, before, []automerge.Hash{after}) + require.NoError(t, err) + result[engine.name] = patches + } + + require.Len(t, result["reference"], 1) + assert.Equal(t, automerge.PatchMark, result["reference"][0].Action) + require.Len(t, result["reference"][0].Marks, 1) + assert.Equal(t, automerge.ScalarTypeNull, result["reference"][0].Marks[0].Value.Type) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestTextDiff_MarkValueChangeMatchesReference verifies that changing a mark +// value emits a mark patch with the new value on both engines. +func TestTextDiff_MarkValueChangeMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + result := make(map[string][]automerge.Patch) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "hello world") + require.NoError(t, text.Mark( + ctx, + 0, + 5, + "color", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "red"}, + automerge.MarkExpandBoth, + )) + _, err := document.Commit(ctx, "red", commitTime) + require.NoError(t, err) + + before, err := document.Heads(ctx) + require.NoError(t, err) + require.NoError(t, text.Mark( + ctx, + 0, + 5, + "color", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "blue"}, + automerge.MarkExpandBoth, + )) + after, err := document.Commit(ctx, "blue", commitTime) + require.NoError(t, err) + + patches, err := document.Diff(ctx, before, []automerge.Hash{after}) + require.NoError(t, err) + result[engine.name] = patches + } + + require.Len(t, result["reference"], 1) + require.Len(t, result["reference"][0].Marks, 1) + assert.Equal(t, "blue", result["reference"][0].Marks[0].Value.String) + assert.Equal(t, result["reference"], result["native"]) +} + +// TestRustTextEncoding_SplitBlock reproduces the utf16 case of split_block. +func TestRustTextEncoding_SplitBlock(t *testing.T) { + t.Parallel() + + ctx := context.Background() + results := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, _, text := seedText(t, ctx, engine, "he"+familyEmoji+"llo") + _, err := text.SplitBlock(ctx, 13) + require.NoError(t, err) + _, err = document.Commit(ctx, "split", commitTime) + require.NoError(t, err) + + spans, err := text.Spans(ctx) + require.NoError(t, err) + + texts := make([]string, 0, len(spans)) + + for _, span := range spans { + if span.Type == automerge.SpanTypeText { + texts = append(texts, span.Text) + } + } + + results[engine.name] = texts + } + + assert.Equal(t, []string{"he" + familyEmoji, "llo"}, results["reference"]) + assert.Equal(t, results["reference"], results["native"]) +} diff --git a/pkg/automerge/update_text_parity_test.go b/pkg/automerge/update_text_parity_test.go new file mode 100644 index 0000000000..8a3347d253 --- /dev/null +++ b/pkg/automerge/update_text_parity_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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. + +// The tests in this file reproduce upstream Rust update_text tests from +// automerge 0.10 (rust/automerge/tests/text.rs). update_text computes a minimal +// grapheme-aware diff so concurrent edits to disjoint regions merge cleanly. +// Each scenario runs identically on the native Go engine and the Rust/WASM +// reference engine and asserts their materialized text and change history agree. + +package automerge_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRustText_SimpleUpdateText reproduces simple_update_text: two forks edit +// disjoint words with update_text and merge into a document combining both. +func TestRustText_SimpleUpdateText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + merged := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Hello, world!")) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, other) + + otherObject, err := other.Root().Object(ctx, "text") + require.NoError(t, err) + otherText, err := otherObject.Text() + require.NoError(t, err) + require.NoError(t, otherText.Update(ctx, "Goodbye, world!")) + _, err = other.Commit(ctx, "goodbye", commitTime) + require.NoError(t, err) + + require.NoError(t, text.Update(ctx, "Hello, friends!")) + _, err = document.Commit(ctx, "friends", commitTime) + require.NoError(t, err) + + _, err = document.Merge(ctx, other) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + merged[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "Goodbye, friends!", merged["reference"]) + assert.Equal(t, merged["reference"], merged["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +} + +// TestRustText_UpdateTextBigOleGraphemes reproduces update_text_big_ole_graphemes: +// update_text treats emoji ZWJ sequences as single grapheme clusters, so two +// forks that swap the family emoji merge into both new families side by side. +func TestRustText_UpdateTextBigOleGraphemes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + merged := make(map[string]string) + heads := make(map[string][]string) + + for _, engine := range rustParityEngines() { + document, err := engine.open(ctx, actor(0xaa)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "leftπŸ‘¨β€πŸ‘©β€πŸ‘¦right")) + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + other, err := document.Fork(ctx, actor(0xbb)) + require.NoError(t, err) + closeDocument(t, other) + + otherObject, err := other.Root().Object(ctx, "text") + require.NoError(t, err) + otherText, err := otherObject.Text() + require.NoError(t, err) + require.NoError(t, otherText.Update(ctx, "leftπŸ‘¨β€πŸ‘©β€πŸ‘§right")) + _, err = other.Commit(ctx, "girl", commitTime) + require.NoError(t, err) + + require.NoError(t, text.Update(ctx, "leftπŸ‘¨β€πŸ‘©β€πŸ‘¦β€πŸ‘¦right")) + _, err = document.Commit(ctx, "boys", commitTime) + require.NoError(t, err) + + _, err = document.Merge(ctx, other) + require.NoError(t, err) + + result, err := text.String(ctx) + require.NoError(t, err) + merged[engine.name] = result + heads[engine.name] = sortedHeadHex(t, ctx, document) + } + + assert.Equal(t, "leftπŸ‘¨β€πŸ‘©β€πŸ‘§πŸ‘¨β€πŸ‘©β€πŸ‘¦β€πŸ‘¦right", merged["reference"]) + assert.Equal(t, merged["reference"], merged["native"]) + assert.Equal(t, heads["reference"], heads["native"]) +}