diff --git a/.github/workflows/automerge-battery.yaml b/.github/workflows/automerge-battery.yaml new file mode 100644 index 0000000000..91bd826ca7 --- /dev/null +++ b/.github/workflows/automerge-battery.yaml @@ -0,0 +1,35 @@ +name: "automerge benchmark battery" + +on: + workflow_dispatch: + schedule: + - cron: "17 4 * * 1" + +permissions: + contents: "read" + +jobs: + official-fast-tier: + name: "official fast tier" + runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache" + timeout-minutes: 30 + steps: + - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v6 + - uses: "runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc" # v2 + - uses: "./.github/actions/setup" + with: + node: "false" + - name: "Run pinned upstream battery" + run: | + make benchmark-automerge-official \ + AUTOMERGE_BATTERY_OUTPUT="${RUNNER_TEMP}/automerge-battery.json" + - name: "Replay official battery fixtures through Go and Rust" + run: | + make test-automerge-official-fixtures \ + AUTOMERGE_BATTERY_FIXTURES="${RUNNER_TEMP}/automerge-battery-fixtures" + - uses: "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" # v7 + with: + name: "automerge-battery-${{ github.sha }}" + path: "${{ runner.temp }}/automerge-battery.json" + if-no-files-found: "error" + retention-days: 30 diff --git a/.github/workflows/make.yaml b/.github/workflows/make.yaml index c4730f06ae..43bca618fd 100644 --- a/.github/workflows/make.yaml +++ b/.github/workflows/make.yaml @@ -374,6 +374,24 @@ jobs: reviewdog -f=eslint -reporter=github-pr-review -filter-mode=nofilter -name="eslint ($dir)" || true done + test-js: + name: "test-js" + runs-on: "runs-on=${{ github.run_id }}/runner=4cpu-linux-x64/extras=s3-cache" + permissions: + contents: "read" + steps: + - uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1" # v6 + with: + submodules: recursive + - uses: "runs-on/action@4e5f72399b6b17f2e79c511c1b38a315a64d22dc" # v2 + - uses: "./.github/actions/setup" + with: + go: "false" + - name: "Generate Relay artifacts" + run: make relay + - name: "Run vitest" + run: make test-js + lint-swift: name: "lint-swift" runs-on: "runs-on=${{ github.run_id }}/runner=2cpu-linux-x64/extras=s3-cache" diff --git a/.gitignore b/.gitignore index 6fbae5ebc5..78f816145c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ +pkg/automerge/internal/reference/wasm/target/ node_modules/ .cache/ .turbo diff --git a/.trufflehog-exclude b/.trufflehog-exclude index 62c43c2767..5092a25483 100644 --- a/.trufflehog-exclude +++ b/.trufflehog-exclude @@ -1,2 +1,5 @@ pkg/agent/guardrail/sensitive_data_test\.go pkg/validator/validator_format_test\.go +pkg/automerge/current_state_parity_test\.go +pkg/automerge/testdata/upstream-parity\.json +packages/automerge-conformance/parity-mappings\.json diff --git a/GNUmakefile b/GNUmakefile index 247103007c..563baca21d 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -2,6 +2,7 @@ NPROC ?= $(shell nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || getconf _ MAKEFLAGS := --jobs=$(NPROC) CAT ?= cat +CARGO ?= cargo CP ?= cp DOCKER ?= docker GO ?= go @@ -11,14 +12,20 @@ MKCERT ?= mkcert MKDIR ?= mkdir -p NPM ?= npm NPX ?= npx +NODE ?= node OPENSSL ?= openssl +AUTOMERGE_BATTERY_OUTPUT ?= $(CURDIR)/.cache/automerge-battery.json +AUTOMERGE_BATTERY_FIXTURES ?= $(CURDIR)/.cache/automerge-battery-fixtures SED ?= sed +SHA256SUM ?= sha256sum SYFT ?= syft TAIL ?= tail ECHO ?= echo 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 @@ -37,6 +44,7 @@ SHELL_SCRIPTS := \ cmd/probo-agent/installer/macos/reinstall.sh \ cmd/probo-agent/installer/macos/uninstall.sh \ compose/postgres/01_probod.sh \ + contrib/benchmarks/automerge-battery.sh \ contrib/lima/provision.sh \ contrib/lima/sandbox.sh \ contrib/merge-graphql-schema.sh \ @@ -104,6 +112,9 @@ EMBEDDED= apps/console/dist/index.html \ apps/compliance-portal/dist/index.html \ @probo/emails +AUTOMERGE_REFERENCE_DIR= pkg/automerge/internal/reference +AUTOMERGE_REFERENCE_WASM= $(AUTOMERGE_REFERENCE_DIR)/reference.wasm + PROBOD_BIN_EXTRA_DEPS= PROBOD_BIN= bin/probod PROBOD_SRC= cmd/probod/main.go @@ -200,6 +211,10 @@ test: CGO_ENABLED=1 test: ## Run tests with race detection and coverage (usage: make test [MODULE=./pkg/some/module]) $(GO_TEST) $(if $(MODULE),$(MODULE),$(shell $(GO) list ./... | grep -v /e2e/)) +.PHONY: test-js +test-js: ## Run frontend unit tests (vitest) across workspaces + $(NPM) run test + .PHONY: test-verbose test-verbose: TEST_FLAGS+=-v test-verbose: test ## Run tests with verbose output @@ -208,6 +223,83 @@ test-verbose: test ## Run tests with verbose output test-short: TEST_FLAGS+=-short test-short: test ## Run short tests only +.PHONY: test-automerge-conformance +test-automerge-conformance: ## Test Go binary compatibility with official Automerge JS + AUTOMERGE_JS_ORACLE=$(CURDIR)/packages/automerge-conformance/oracle.mjs \ + $(GO_BASE) test -count=1 ./pkg/automerge + +.PHONY: generate-prosemirror-parity +generate-prosemirror-parity: ## Regenerate the ProseMirror render parity fixture from @automerge/prosemirror + GEN_PROSEMIRROR_PARITY=1 $(NPX) vitest run \ + --root packages/ui src/RichEditor/prosemirrorRenderParity.test.ts + +.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: benchmark-automerge-official +benchmark-automerge-official: ## Run the pinned official Automerge fast benchmark battery + @mkdir -p $(dir $(AUTOMERGE_BATTERY_OUTPUT)) + contrib/benchmarks/automerge-battery.sh run \ + --tier fast \ + --output $(AUTOMERGE_BATTERY_OUTPUT) + +.PHONY: list-automerge-official-benchmarks +list-automerge-official-benchmarks: ## List the pinned official Automerge benchmark battery + contrib/benchmarks/automerge-battery.sh list --tier all + +.PHONY: test-automerge-official-fixtures +test-automerge-official-fixtures: ## Replay official benchmark-battery documents through Rust and Go + rm -rf $(AUTOMERGE_BATTERY_FIXTURES) + cargo +1.90.0 run --release --locked \ + --manifest-path packages/automerge-benchmark/official-fixtures/Cargo.toml \ + -- $(AUTOMERGE_BATTERY_FIXTURES) + AUTOMERGE_OFFICIAL_BATTERY_FIXTURES=$(AUTOMERGE_BATTERY_FIXTURES) \ + $(GO_BASE) test -count=1 -run '^TestOfficialBenchmarkBatteryFixtures$$' \ + ./pkg/automerge + +.PHONY: generate-automerge-collaboration-fixtures +generate-automerge-collaboration-fixtures: ## Regenerate automerge-repo protocol fixtures from the pinned JS packages + $(NODE) packages/automerge-conformance/generate-collaboration-fixtures.mjs + +.PHONY: test-automerge-repo-interop +test-automerge-repo-interop: ## Sync a real automerge-repo JS client against the Go gateway + AUTOMERGE_REPO_INTEROP_CLIENT=$(CURDIR)/packages/automerge-conformance/collaboration-interop-client.mjs \ + $(GO_BASE) test -count=1 -run '^TestInterop_' ./pkg/automerge/collaboration + +.PHONY: benchmark-prosemirror +benchmark-prosemirror: ## Benchmark Go rendering and the frontend ProseMirror bridge + $(GO_BASE) test -run '^$$' -bench '^BenchmarkRender$$' -benchmem \ + ./pkg/automerge/prosemirror + $(NPX) vitest bench --run --root packages/ui \ + src/RichEditor/prosemirrorBridge.bench.ts + +.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 + $(GO_BASE) test -run '^$$' -fuzz '^FuzzDecodePresence$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge/collaboration + $(GO_BASE) test -run '^$$' -fuzz '^FuzzDecodeMessage$$' -fuzztime=$(AUTOMERGE_FUZZ_TIME) ./pkg/automerge/collaboration + .PHONY: coverage-report coverage-report: test ## Generate HTML coverage report $(GO) tool cover -html=coverage.out -o coverage.html @@ -414,6 +506,18 @@ pkg/server/api/complianceportal/v1/schema.graphql: pkg/server/api/complianceport .PHONY: generate generate: $(GENERATED) +.PHONY: generate-automerge-reference +generate-automerge-reference: ## Rebuild the embedded official Automerge WASM backend + cd $(AUTOMERGE_REFERENCE_DIR)/wasm && \ + $(CARGO) +$(RUST_TOOLCHAIN) build --locked --release --target wasm32-wasip1 + $(CP) $(AUTOMERGE_REFERENCE_DIR)/wasm/target/wasm32-wasip1/release/probo_automerge_reference.wasm $(AUTOMERGE_REFERENCE_WASM) + cd $(AUTOMERGE_REFERENCE_DIR) && $(SHA256SUM) reference.wasm > reference.wasm.sha256 + +.PHONY: audit-automerge-reference +audit-automerge-reference: ## Audit Automerge Rust advisories, licenses, bans, and sources + cd $(AUTOMERGE_REFERENCE_DIR)/wasm && \ + $(CARGO) +$(RUST_TOOLCHAIN) deny check + .PHONY: embed embed: $(EMBEDDED) diff --git a/apps/console/package.json b/apps/console/package.json index a031e0174a..a19b086a5d 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@automerge/automerge": "^3.4.0", + "@automerge/automerge-repo": "2.6.0-alpha.3", + "@automerge/automerge-repo-network-websocket": "2.6.0-alpha.3", + "@automerge/prosemirror": "^0.2.0", "@hookform/resolvers": "^5.7.1", "@phosphor-icons/react": "^2.1.10", "@probo/coredata": "^1.0.0", diff --git a/apps/console/src/pages/organizations/documents/description/DocumentDescriptionPage.tsx b/apps/console/src/pages/organizations/documents/description/DocumentDescriptionPage.tsx index 3648cd6c6f..fcc6202326 100644 --- a/apps/console/src/pages/organizations/documents/description/DocumentDescriptionPage.tsx +++ b/apps/console/src/pages/organizations/documents/description/DocumentDescriptionPage.tsx @@ -19,10 +19,18 @@ // SOFTWARE. import { formatError } from "@probo/helpers"; -import { RichEditor, useToast } from "@probo/ui"; -import { useCallback, useState } from "react"; +import { + RichEditor, + supportsRichEditorCollaboration, + useToast, +} from "@probo/ui"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { type PreloadedQuery, useMutation, usePreloadedQuery } from "react-relay"; +import { + type PreloadedQuery, + useMutation, + usePreloadedQuery, +} from "react-relay"; import { useOutletContext } from "react-router"; import { graphql } from "relay-runtime"; import { useDebounceCallback } from "usehooks-ts"; @@ -30,10 +38,25 @@ import { useDebounceCallback } from "usehooks-ts"; import type { DocumentDescriptionPage_updateContentMutation } from "#/__generated__/core/DocumentDescriptionPage_updateContentMutation.graphql"; import type { DocumentDescriptionPageQuery } from "#/__generated__/core/DocumentDescriptionPageQuery.graphql"; +import { + connectRepoDocument, + type RepoCollaborationHandle, +} from "./_lib/connectRepoDocument"; + const autoSaveIntervalMs = 1000; +type CollaborationState = { + versionID: string; + handle?: RepoCollaborationHandle; + failed?: boolean; +}; + export const documentDescriptionPageQuery = graphql` - query DocumentDescriptionPageQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) { + query DocumentDescriptionPageQuery( + $documentId: ID! + $versionId: ID! + $versionSpecified: Boolean! + ) { # We use this on /documents/:documentId/versions/:versionId/description version: node(id: $versionId) @include(if: $versionSpecified) { __typename @@ -51,7 +74,10 @@ export const documentDescriptionPageQuery = graphql` writeMode canUpdate: permission(action: "core:document:update") # We use this on /documents/:documentId/description - lastVersion: versions(first: 1 orderBy: { field: CREATED_AT, direction: DESC }) @skip(if: $versionSpecified) { + lastVersion: versions( + first: 1 + orderBy: { field: CREATED_AT, direction: DESC } + ) @skip(if: $versionSpecified) { edges { node { id @@ -66,7 +92,9 @@ export const documentDescriptionPageQuery = graphql` `; const updateContentMutation = graphql` - mutation DocumentDescriptionPage_updateContentMutation($input: UpdateDocumentInput!) { + mutation DocumentDescriptionPage_updateContentMutation( + $input: UpdateDocumentInput! + ) { updateDocument(input: $input) { document { id @@ -97,7 +125,10 @@ export function DocumentDescriptionPage(props: { documentDescriptionPageQuery, queryRef, ); - if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) { + if ( + document.__typename !== "Document" || + (version && version.__typename !== "DocumentVersion") + ) { throw new Error("invalid type for node"); } @@ -107,57 +138,124 @@ export function DocumentDescriptionPage(props: { throw new Error("Document version not found"); } - const [updateContent] = useMutation(updateContentMutation); + const [updateContent] = + useMutation( + updateContentMutation, + ); const documentId = document.id; const wasDraft = currentVersion.status === "DRAFT"; const handleUpdate = useDebounceCallback( - useCallback((content: string) => { - updateContent({ - variables: { - input: { - id: documentId, - content, + useCallback( + (content: string) => { + updateContent({ + variables: { + input: { + id: documentId, + content, + }, }, - }, - onCompleted: (data, errors) => { - if (errors?.length) { + onCompleted: (data, errors) => { + if (errors?.length) { + toast({ + title: t("documentDescriptionPage.errors.title"), + description: formatError( + t("documentDescriptionPage.errors.save"), + errors, + ), + variant: "error", + }); + return; + } + + const draftReturned = !!data.updateDocument.documentVersion; + if (wasDraft !== draftReturned) { + onDocumentUpdated(); + } + + toast({ + title: t("documentDescriptionPage.messages.successTitle"), + description: t("documentDescriptionPage.messages.saved"), + variant: "success", + }); + }, + onError: (error) => { toast({ title: t("documentDescriptionPage.errors.title"), - description: formatError(t("documentDescriptionPage.errors.save"), errors), + description: + error.message ?? t("documentDescriptionPage.errors.save"), variant: "error", }); - return; - } + }, + }); + }, + [documentId, wasDraft, updateContent, toast, t, onDocumentUpdated], + ), + autoSaveIntervalMs, + ); - const draftReturned = !!data.updateDocument.documentVersion; - if (wasDraft !== draftReturned) { - onDocumentUpdated(); - } + const canEdit = + isEditable && + document.canUpdate && + document.status !== "ARCHIVED" && + document.writeMode !== "GENERATED"; + const collaborationSupported = + canEdit && supportsRichEditorCollaboration(currentVersion.content); + const [collaboration, setCollaboration] = useState(); - toast({ - title: t("documentDescriptionPage.messages.successTitle"), - description: t("documentDescriptionPage.messages.saved"), - variant: "success", - }); - }, - onError: (error) => { - toast({ - title: t("documentDescriptionPage.errors.title"), - description: error.message ?? t("documentDescriptionPage.errors.save"), - variant: "error", - }); - }, + useEffect(() => { + if (!collaborationSupported) return; + + let cancelled = false; + let activeHandle: RepoCollaborationHandle | undefined; + + connectRepoDocument(currentVersion.id) + .then((handle) => { + if (cancelled) { + handle.close(); + return; + } + + activeHandle = handle; + setCollaboration({ + versionID: currentVersion.id, + handle, + }); + }) + .catch((error) => { + if (cancelled) return; + + setCollaboration({ + versionID: currentVersion.id, + failed: true, + }); + toast({ + title: t("documentDescriptionPage.errors.title"), + description: + error instanceof Error + ? error.message + : t("documentDescriptionPage.errors.save"), + variant: "error", + }); }); - }, [documentId, wasDraft, updateContent, toast, t, onDocumentUpdated]), - autoSaveIntervalMs, - ); - const canEdit = isEditable - && document.canUpdate - && document.status !== "ARCHIVED" - && document.writeMode !== "GENERATED"; + return () => { + cancelled = true; + activeHandle?.close(); + }; + }, [collaborationSupported, currentVersion.id, t, toast]); + + const collaborationConnecting = + collaborationSupported && + (collaboration?.versionID !== currentVersion.id || + (!collaboration?.handle && !collaboration?.failed)); + const collaborationHandle = + collaborationSupported && + collaboration?.versionID === currentVersion.id && + !collaboration.failed + ? collaboration.handle + : undefined; // The editor key must change on explicit actions (delete draft, edit // title/type) but NOT on auto-save side effects (cursor preservation). @@ -175,7 +273,7 @@ export function DocumentDescriptionPage(props: { if (currentVersion.id !== prevVersionId) { // Both changed at once — data was already available. setPrevVersionId(currentVersion.id); - setDataGeneration(g => g + 1); + setDataGeneration((g) => g + 1); setPendingExplicit(false); } else { // Explicit action fired but data hasn't arrived yet. @@ -185,7 +283,7 @@ export function DocumentDescriptionPage(props: { setPrevVersionId(currentVersion.id); if (pendingExplicit) { // Fresh data arrived for a pending explicit action — remount. - setDataGeneration(g => g + 1); + setDataGeneration((g) => g + 1); setPendingExplicit(false); } // Otherwise auto-save changed the version — don't bump generation. @@ -199,7 +297,8 @@ export function DocumentDescriptionPage(props: { className="flex-1" content={currentVersion.content} data-theme="document" - disabled={!canEdit} + disabled={!canEdit || collaborationConnecting} + collaborationHandle={collaborationHandle} onChangeContent={handleUpdate} /> ); diff --git a/apps/console/src/pages/organizations/documents/description/_lib/connectRepoDocument.ts b/apps/console/src/pages/organizations/documents/description/_lib/connectRepoDocument.ts new file mode 100644 index 0000000000..4bc5a9a620 --- /dev/null +++ b/apps/console/src/pages/organizations/documents/description/_lib/connectRepoDocument.ts @@ -0,0 +1,181 @@ +// 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 { + type AutomergeUrl, + type DocHandle, + type DocHandleEphemeralMessagePayload, + Repo, +} from "@automerge/automerge-repo"; +import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import { + deriveAutomergeUrl, + pmSelectionFromPresence, + presenceFromPmSelection, + type RichEditorAutomergeDocument, + type RichEditorCollaborationHandle, + type RichEditorPresence, + richEditorPresenceAdapter, + type TextSelection, +} from "@probo/ui"; + +// A collaboration handle for the editor, plus a close() that tears down the repo +// and its network connection. +export type RepoCollaborationHandle = RichEditorCollaborationHandle & { + close: () => void; +}; + +// How long a remote collaborator's cursor lingers after their last update before +// it is pruned, so a caret does not stay behind when a peer goes away without a +// clean disconnect. +const presenceTimeToLiveMs = 30_000; + +const initializationTimeoutMs = 35_000; + +// The presence payload broadcast over repo ephemeral messages: a caret or +// selection expressed as stable Automerge cursors. +type SelectionPresence = { + kind: "selection"; + selection: TextSelection; +}; + +function isSelectionPresence(value: unknown): value is SelectionPresence { + if (!value || typeof value !== "object") return false; + const message = value as Record; + return message.kind === "selection" && !!message.selection; +} + +// connectRepoDocument connects to the automerge-repo collaboration endpoint for a +// document version, finds the (server-seeded) document, and returns a handle the +// editor can drive. Presence rides repo ephemeral messages carrying stable +// cursors, so remote carets stay anchored while other people type. +export async function connectRepoDocument( + documentVersionID: string, +): Promise { + const endpoint = new URL(window.location.origin); + endpoint.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:"; + endpoint.pathname = [ + "api", + "console", + "v1", + "document-versions", + encodeURIComponent(documentVersionID), + "repo", + ].join("/"); + + const network = new WebSocketClientAdapter(endpoint.toString()); + const repo = new Repo({ network: [network] }); + + const documentURL = (await deriveAutomergeUrl( + documentVersionID, + )) as AutomergeUrl; + + let handle: DocHandle; + try { + handle = await repo.find(documentURL, { + signal: AbortSignal.timeout(initializationTimeoutMs), + }); + } catch (error) { + void repo.shutdown(); + throw error; + } + + const adapter = richEditorPresenceAdapter(); + const collaboration = handle as unknown as RepoCollaborationHandle; + + collaboration.updatePresence = (anchorPosition, headPosition) => { + const document = handle.doc(); + if (!document) return; + + const selection = presenceFromPmSelection( + adapter, + document, + anchorPosition, + headPosition, + ); + if (!selection) return; + + handle.broadcast({ + kind: "selection", + selection, + } satisfies SelectionPresence); + }; + + collaboration.onPresence = (listener) => { + // Presence is decoration state: the editor replaces all remote cursors on + // each update, so accumulate the latest selection per peer and emit the full + // set, pruning peers that have gone quiet. + const latest = new Map< + string, + { presence: RichEditorPresence; at: number } + >(); + + const handleEphemeral = ( + payload: DocHandleEphemeralMessagePayload, + ) => { + if (!isSelectionPresence(payload.message)) return; + + const document = handle.doc(); + if (!document) return; + + const resolved = pmSelectionFromPresence( + adapter, + document, + payload.message.selection, + ); + const sender = String(payload.senderId); + + const now = Date.now(); + if (resolved) { + latest.set(sender, { + at: now, + presence: { + connectionID: sender, + identityID: sender, + anchorPosition: resolved.anchorPosition, + headPosition: resolved.headPosition, + }, + }); + } + + const presences: RichEditorPresence[] = []; + for (const [peer, entry] of latest) { + if (now - entry.at > presenceTimeToLiveMs) { + latest.delete(peer); + continue; + } + + presences.push(entry.presence); + } + + listener(presences); + }; + + handle.on("ephemeral-message", handleEphemeral); + + return () => handle.off("ephemeral-message", handleEphemeral); + }; + + collaboration.close = () => { + void repo.shutdown(); + }; + + return collaboration; +} diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 43a46e2e8a..3a2ef33ff4 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ "/api": { target: "http://localhost:8080", changeOrigin: true, + ws: true, }, }, }, diff --git a/contrib/benchmarks/automerge-battery.sh b/contrib/benchmarks/automerge-battery.sh new file mode 100755 index 0000000000..374f1ffc85 --- /dev/null +++ b/contrib/benchmarks/automerge-battery.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# +# 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. + +set -euo pipefail + +# Pin the real upstream battery. Bump this deliberately after reviewing upstream +# workload/schema changes; never benchmark a moving main branch in CI. +readonly DEFAULT_REF="e4f9420a63b5ebfd079de7f22a852c2abb6e2774" +readonly REF="${AUTOMERGE_BATTERY_REF:-$DEFAULT_REF}" +readonly TOOLCHAIN="${AUTOMERGE_BATTERY_RUST_TOOLCHAIN:-1.90.0}" +readonly CACHE_ROOT="${XDG_CACHE_HOME:-$HOME/.cache}/probo/automerge-battery" +readonly CHECKOUT="$CACHE_ROOT/$REF" + +if [[ ! -d "$CHECKOUT/.git" ]]; then + mkdir -p "$CHECKOUT" + git -C "$CHECKOUT" init --quiet + git -C "$CHECKOUT" remote add origin https://github.com/automerge/automerge.git +fi + +if ! git -C "$CHECKOUT" cat-file -e "$REF^{commit}" 2>/dev/null; then + git -C "$CHECKOUT" fetch --quiet --depth 1 origin "$REF" +fi + +git -C "$CHECKOUT" checkout --quiet --detach "$REF" + +if ! rustup run "$TOOLCHAIN" rustc --version >/dev/null 2>&1; then + rustup toolchain install "$TOOLCHAIN" --profile minimal +fi + +exec cargo +"$TOOLCHAIN" run \ + --release \ + --manifest-path "$CHECKOUT/rust/Cargo.toml" \ + -p benchmark-battery \ + -- "$@" diff --git a/go.mod b/go.mod index 07bd220066..4295e9b013 100644 --- a/go.mod +++ b/go.mod @@ -20,8 +20,10 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/chromedp/cdproto v0.0.0-20260719223732-95f6af754cfe github.com/chromedp/chromedp v0.16.0 + github.com/coder/websocket v1.8.15 github.com/crewjam/saml v0.5.1 github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea + github.com/fxamacker/cbor/v2 v2.9.2 github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/cors v1.2.2 github.com/go-git/go-git/v5 v5.19.2 @@ -34,9 +36,11 @@ 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 + github.com/tetratelabs/wazero v1.12.0 github.com/vektah/gqlparser/v2 v2.5.36 github.com/vikstrous/dataloadgen v0.0.10 github.com/yuin/goldmark v1.8.5 @@ -81,7 +85,6 @@ require ( github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/cloudflare/circl v1.6.4 // indirect - github.com/coder/websocket v1.8.15 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -136,7 +139,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 @@ -150,6 +152,7 @@ require ( github.com/theupdateframework/go-tuf/v2 v2.4.2 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect diff --git a/go.sum b/go.sum index 16ff1c5ec9..f59640afee 100644 --- a/go.sum +++ b/go.sum @@ -225,6 +225,8 @@ github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeO github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3 h1:bn2ml0JxH4DtQqi+CZ+ZPUBo1i3K+4xD0rHczpvpqFk= github.com/getprobo/scim v0.0.0-20260309220528-a952b258e8d3/go.mod h1:njybYNBd7EDyRMan05ticVQjPP6ThiuyCDbtRh9+e8A= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= @@ -572,6 +574,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= @@ -605,6 +609,8 @@ github.com/vektah/gqlparser/v2 v2.5.36 h1:CN9mKVHgMkc+XftdOWIhb4HEL8wKSYkFAqhf8b github.com/vektah/gqlparser/v2 v2.5.36/go.mod h1:cAJ9qwVgPaUkWv6Gn8vn0mqOE0Ui5Pn56wNy5396XWo= github.com/vikstrous/dataloadgen v0.0.10 h1:x07XAeEjIWXohvcjRvE72KY8pV5A3sTbKEFmxcj9RNM= github.com/vikstrous/dataloadgen v0.0.10/go.mod h1:8vuQVpBH0ODbMKAPUdCAPcOGezoTIhgAjgex51t4vbg= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/package-lock.json b/package-lock.json index 4baff0cd45..c904c6bd93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -339,6 +339,8 @@ "name": "@probo/console", "version": "0.0.0", "dependencies": { + "@automerge/automerge": "^3.4.0", + "@automerge/prosemirror": "^0.2.0", "@hookform/resolvers": "^5.7.1", "@phosphor-icons/react": "^2.1.10", "@probo/coredata": "^1.0.0", @@ -771,6 +773,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -853,7 +856,6 @@ "integrity": "sha512-IjLt0gd3L4jlOfilxVXTifn42FnVffMgDC04RJK1KDZpmkBWLv0XC92MVVmkxrFZNS/7l3xWgP/I3nqtX1sQHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -870,7 +872,6 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -880,8 +881,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@apm-js-collab/code-transformer": { "version": "0.18.1", @@ -1017,6 +1017,77 @@ "integrity": "sha512-BQebYH9nV1VZttZwoq/fsxcxIJjc8oW2bNV6yJDHLZ8OF8UtM15drFN0JOL8Jwn7jeeD7Ev+tIC8pUijJEditQ==", "license": "MIT" }, + "node_modules/@automerge/automerge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-3.4.0.tgz", + "integrity": "sha512-THmghtTNGGt2xsI0pM3o1i3PM8oZKcYFgOj25FOzW7l6e94SQOivNtCwy6xc0I8hVJsQSSotoBNs+yk/9hM2dg==", + "license": "MIT" + }, + "node_modules/@automerge/automerge-repo": { + "version": "2.6.0-alpha.3", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.6.0-alpha.3.tgz", + "integrity": "sha512-Rn/KdoVHUQwYU0TXqHyy9PdBgVE009JJWnh2YxT2blk5EnbZckh+RfGfu6ngjMGw0DZcZtJLvK3dKDNdAvtQVA==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "^3.2.6", + "bs58check": "^4.0.0", + "cbor-x": "^1.6.4", + "debug": "^4.4.3", + "eventemitter3": "^5.0.4", + "fast-sha256": "^1.3.0", + "uuid": "^14.0.1", + "xstate": "^5.32.4" + }, + "engines": { + "node": ">=22.13" + } + }, + "node_modules/@automerge/automerge-repo-network-websocket": { + "version": "2.6.0-alpha.3", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-network-websocket/-/automerge-repo-network-websocket-2.6.0-alpha.3.tgz", + "integrity": "sha512-8iY+aQm0IZfKjwqFAEmwJLT/QP0izwq/F45mtX0L6Bl+JQ0p9ouJcqYwFyhWItlb7vor3NKMb/yp3NCGmy5A4w==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.6.0-alpha.3", + "cbor-x": "^1.6.4", + "debug": "^4.4.3", + "eventemitter3": "^5.0.4", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "node_modules/@automerge/automerge-repo-network-websocket/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/@automerge/automerge-repo/node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/@automerge/prosemirror": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@automerge/prosemirror/-/prosemirror-0.2.0.tgz", + "integrity": "sha512-Mixxftvfs12tKi7DsStobrQxFayoLJXhEjbrnmNnaQbMzwHrUzuwac+OUvw6ujbtXaulWX+JE8hAuHFAGRDsvQ==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "^3.1", + "ordered-map": "^0.1.0", + "prosemirror-changeset": "^2.2.1", + "prosemirror-history": "^1.4.1", + "prosemirror-model": "^1.25.2", + "prosemirror-schema-basic": "^1.2.2", + "prosemirror-schema-list": "^1.3.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.7.3", + "prosemirror-view": "^1.40.1" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1341,7 +1412,6 @@ "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Borewit" @@ -1359,7 +1429,6 @@ "integrity": "sha512-mPAuLRU9jWR7o0KJi9+gQnOBDUSIkoKbbFv4HjrA+80qWVcFacrNPlZmf4mguQnfZ0oP2t5c3ws6yuFyAX9vpA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", @@ -1376,7 +1445,6 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1386,8 +1454,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@browserbasehq/stagehand": { "version": "1.14.0", @@ -1395,7 +1462,6 @@ "integrity": "sha512-Hi/EzgMFWz+FKyepxHTrqfTPjpsuBS4zRy3e9sbMpBgLPv+9c0R+YZEvS7Bw4mTS66QtvvURRT6zgDGFotthVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@anthropic-ai/sdk": "^0.27.3", "@browserbasehq/sdk": "^2.0.0", @@ -1416,11 +1482,88 @@ "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dev": true, "license": "ISC", - "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz", + "integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz", + "integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz", + "integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz", + "integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz", + "integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz", + "integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -1550,29 +1693,6 @@ "kuler": "^2.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -2260,6 +2380,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" @@ -2568,6 +2689,7 @@ "integrity": "sha512-nXmyH0FbcsASlRmC9sbqX0gjQdxgB9KcS13vkw9PMaH0zzylwZkGFU9sY0XCPa2/AokmaNTU9DOW3IUDfAtQow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "@standard-schema/spec": "^1.1.0", @@ -3486,6 +3608,7 @@ "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", @@ -3517,6 +3640,7 @@ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3648,6 +3772,7 @@ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3785,6 +3910,7 @@ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3947,6 +4073,7 @@ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -3966,7 +4093,6 @@ "resolved": "https://registry.npmjs.org/@n8n/errors/-/errors-0.10.0.tgz", "integrity": "sha512-Qle4gCsx7qtDRMdie3YkjuXPuo369PP32cR9Svb+INwcjLMxNzmnbJ66jFnOmLn0ntwBccUw1IecJ6axwyTgBQ==", "license": "SEE LICENSE IN LICENSE.md", - "peer": true, "dependencies": { "callsites": "3.1.0" } @@ -3976,7 +4102,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3986,7 +4111,6 @@ "resolved": "https://registry.npmjs.org/@n8n/expression-runtime/-/expression-runtime-0.19.0.tgz", "integrity": "sha512-ShRp3TEue2dNaP/PIQzD5DqyYUjkiaIsDVwsyfAvhsdyIUKyishsHsnogv527vjuA/WzH5cYn9X75lFeQA8hZg==", "license": "SEE LICENSE IN LICENSE.md", - "peer": true, "dependencies": { "@n8n/errors": "0.10.0", "@n8n/tournament": "1.4.0", @@ -4007,7 +4131,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -4160,6 +4283,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4308,7 +4432,6 @@ "resolved": "https://registry.npmjs.org/@n8n/tournament/-/tournament-1.4.0.tgz", "integrity": "sha512-llDdmIj3Kdfq2vX/NpLBOUU4kLjhITtIMeJmOSTApAOeAArj1D45ey4D7CFaYcYsPBw6DgF0vxifyskPDlgekw==", "license": "SEE LICENSE IN LICENSE.md", - "peer": true, "dependencies": { "ast-types": "^0.16.1", "esprima-next": "^5.8.4", @@ -4548,6 +4671,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4635,6 +4770,7 @@ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4658,6 +4794,7 @@ "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -4674,6 +4811,7 @@ "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -4727,6 +4865,7 @@ "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", @@ -5469,6 +5608,14 @@ "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 + }, "node_modules/@probo/compliance-portal": { "resolved": "apps/compliance-portal", "link": true @@ -6829,7 +6976,8 @@ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@standard-schema/utils": { "version": "0.3.0", @@ -7315,27 +7463,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -7556,6 +7683,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz", "integrity": "sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -7745,6 +7873,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz", "integrity": "sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -7850,6 +7979,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz", "integrity": "sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -7864,6 +7994,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz", "integrity": "sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.4.1", "prosemirror-commands": "^1.7.1", @@ -7926,7 +8057,6 @@ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" @@ -7944,8 +8074,7 @@ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@ts-morph/common": { "version": "0.28.1", @@ -8492,7 +8621,6 @@ "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -8510,6 +8638,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8519,6 +8648,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8553,8 +8683,7 @@ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.0.tgz", "integrity": "sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/triple-beam": { "version": "1.3.5", @@ -8636,6 +8765,7 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -9676,6 +9806,7 @@ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" @@ -9738,7 +9869,6 @@ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -9765,6 +9895,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9798,7 +9929,6 @@ "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "humanize-ms": "^1.2.1" }, @@ -9812,6 +9942,7 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9828,6 +9959,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -10234,6 +10366,7 @@ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -10404,6 +10537,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -10515,6 +10654,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -10529,13 +10669,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/buildcheck": { "version": "0.0.7", @@ -10684,7 +10842,6 @@ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10713,6 +10870,37 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cbor-extract": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz", + "integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.2", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm": "2.2.2", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.2", + "@cbor-extract/cbor-extract-linux-x64": "2.2.2", + "@cbor-extract/cbor-extract-win32-x64": "2.2.2" + } + }, + "node_modules/cbor-x": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.5.tgz", + "integrity": "sha512-yO64CxnSh6kp+pHNRK9IfwnMvCB+c8HvmUjQY/9l9YRF0/cAPka/tUHLwS64QqUpFCq3/OtbKziVJYXH2EaRig==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.2" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -11384,6 +11572,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz", "integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -11577,6 +11766,7 @@ "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", "license": "ISC", + "peer": true, "dependencies": { "commander": "7", "iconv-lite": "0.6", @@ -11784,6 +11974,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12017,6 +12208,7 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12129,7 +12321,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -12302,7 +12494,6 @@ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "safe-buffer": "^5.0.1" } @@ -12712,6 +12903,7 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -13169,7 +13361,6 @@ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -13256,6 +13447,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", @@ -13372,7 +13569,6 @@ "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", @@ -13551,8 +13747,7 @@ "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/formdata-node": { "version": "4.4.1", @@ -13560,7 +13755,6 @@ "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" @@ -14210,7 +14404,6 @@ "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "^2.0.0" } @@ -14234,6 +14427,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, @@ -14249,7 +14443,6 @@ "integrity": "sha512-7balLY8WKk+bOhe5Vgg4zG2X6Z0zhpG/3VtYPC69evj+lVJSId8xYP7ISRzRfoeXAlJfzLNMbi2Na/0IdDiIkQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@types/debug": "4.1.12", "@types/node": "18.19.80", @@ -14278,7 +14471,6 @@ "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/ms": "*" } @@ -14289,7 +14481,6 @@ "integrity": "sha512-kEWeMwMeIvxYkeg1gTc01awpwLbfMRZXdIhwRcakd/KlK53jmRC26LqcbIt7fnAQTu5GzlnWmzA3H6+l1u6xxQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -14300,7 +14491,6 @@ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ms": "2.1.2" }, @@ -14319,7 +14509,6 @@ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=12" }, @@ -14332,16 +14521,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ibm-cloud-sdk-core/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.6.3", @@ -14374,8 +14561,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -14383,6 +14569,7 @@ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4" } @@ -15182,8 +15369,7 @@ "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -15454,7 +15640,6 @@ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", @@ -15478,7 +15663,6 @@ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -15517,7 +15701,6 @@ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -15530,7 +15713,6 @@ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" @@ -15978,7 +16160,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=13.2.0" } @@ -16003,7 +16184,8 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.18.1", @@ -16029,48 +16211,42 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.isstring": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -16084,8 +16260,7 @@ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/log-symbols": { "version": "7.0.1", @@ -17377,7 +17552,6 @@ "resolved": "https://registry.npmjs.org/n8n-workflow/-/n8n-workflow-2.28.2.tgz", "integrity": "sha512-fGJ6yxRdgeea5EAywfAplYWDLMRNVMZRFikVTFLCBOInxLRDB89LL66sZHx2SC9dl/FALJAGZ6twY5MyUlc+HA==", "license": "SEE LICENSE IN LICENSE.md", - "peer": true, "dependencies": { "@n8n/errors": "0.10.0", "@n8n/expression-runtime": "0.19.0", @@ -17406,7 +17580,6 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -17416,7 +17589,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17515,7 +17687,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=10.5.0" } @@ -17570,6 +17741,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.50", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", @@ -17814,7 +18000,6 @@ "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -17824,8 +18009,7 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/openapi-types": { "version": "12.1.3", @@ -17852,6 +18036,12 @@ "node": ">= 0.8.0" } }, + "node_modules/ordered-map": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ordered-map/-/ordered-map-0.1.0.tgz", + "integrity": "sha512-ttpssyEqKXIeTapKeFTFG+oUCnrC+Fmyy1DPjys0vPJtccBE3baKDFTbJPDuKd9/XHbnKKe+KPQKTzBLri6Mtw==", + "license": "MIT" + }, "node_modules/orderedmap": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", @@ -18253,6 +18443,7 @@ "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@napi-rs/canvas": "0.1.80", "pdfjs-dist": "5.4.296" @@ -18678,6 +18869,15 @@ "orderedmap": "^2.0.0" } }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -18746,7 +18946,6 @@ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -18791,8 +18990,7 @@ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -18820,6 +19018,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18874,6 +19073,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19033,6 +19233,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.84.0.tgz", "integrity": "sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19154,6 +19355,7 @@ "resolved": "https://registry.npmjs.org/react-relay/-/react-relay-21.0.1.tgz", "integrity": "sha512-08BBmpdolP8vslClnszG+g4vkcbVHvaoR0BDHyhicwpCfbtnVGe73/MfPja/xj30eLWSP5WPYNqZupTdEPZvtw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "fbjs": "^3.0.2", @@ -19217,6 +19419,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.3.0.tgz", "integrity": "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==", "license": "MIT", + "peer": true, "dependencies": { "cookie-es": "^3.1.1" }, @@ -19419,6 +19622,7 @@ "resolved": "https://registry.npmjs.org/relay-runtime/-/relay-runtime-21.0.1.tgz", "integrity": "sha512-OS+We56sp6gkGVEcce9VCNOeg/sMbxT9unEoerQPgNAICHowSWrXNc6LJxF2OqjypxYF7kaLDSPPiaPii2Pw1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.25.0", "fbjs": "^3.0.2", @@ -19528,8 +19732,7 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reselect": { "version": "5.2.0", @@ -19585,7 +19788,6 @@ "integrity": "sha512-pOLi+Gdll3JekwuFjXO3fTq+L9lzMQGcSq7M5gIjExcl3Gu1hd4XXuf5o3+LuSBsaULQH7DiNbsqPd1chVpQGQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=10.7.0" }, @@ -20203,6 +20405,7 @@ "integrity": "sha512-VhYwqxPySa24CVXKoWD6gCZXx9//DTmo43YpusGuAoHDYj5Osjt8wuBRQVeGoaLUWnHiPWv8S+GYHrJEaBM6Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", @@ -20521,7 +20724,6 @@ "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tokenizer/token": "^0.3.0" }, @@ -20631,6 +20833,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -20662,7 +20865,8 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.3", @@ -20792,7 +20996,6 @@ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", @@ -20822,7 +21025,6 @@ "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -20839,7 +21041,6 @@ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -21127,7 +21328,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -21455,7 +21655,6 @@ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" @@ -21630,6 +21829,7 @@ "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -21984,6 +22184,7 @@ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", @@ -22127,7 +22328,6 @@ "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -22533,6 +22733,16 @@ "dev": true, "license": "MIT" }, + "node_modules/xstate": { + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.32.5.tgz", + "integrity": "sha512-ULazi1oe6wGrXl0Frb6otSlkm5HLifbbVTkMk5kkSKqz4TkxJaVpnl6jOJwKeid3ORPxYyZQgNLUSYX9q65SIA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -22623,6 +22833,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -22689,6 +22900,21 @@ "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", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "^3.4.0", + "@automerge/automerge-repo": "2.6.0-alpha.3", + "@automerge/automerge-repo-network-websocket": "2.6.0-alpha.3" + } + }, "packages/cookie-banner": { "name": "@probo/cookie-banner", "version": "0.13.0", @@ -23417,6 +23643,8 @@ "version": "1.0.0", "dependencies": { "@ariakit/react": "^0.4.35", + "@automerge/automerge": "^3.4.0", + "@automerge/prosemirror": "^0.2.0", "@base-ui/react": "^1.6.0", "@floating-ui/react": "^0.27.20", "@fontsource-variable/inter": "^5.3.0", @@ -23433,6 +23661,7 @@ "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-tabs": "^1.1.21", + "@tiptap/core": "^3.29.2", "@tiptap/extension-table": "^3.29.2", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.29.2", @@ -23562,6 +23791,7 @@ "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.10", diff --git a/package.json b/package.json index bdf0a9f29e..e727aec311 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,9 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "eslint . --concurrency auto && npm -w @probo/n8n-nodes-probo run lint", - "check": "turbo run check", - "relay": "relay-compiler" + "check": "turbo run check", + "test": "turbo run test", + "relay": "relay-compiler" }, "devDependencies": { "@probo/eslint-config": "1.0.0", diff --git a/packages/automerge-benchmark/.gitignore b/packages/automerge-benchmark/.gitignore new file mode 100644 index 0000000000..7fdc42fbce --- /dev/null +++ b/packages/automerge-benchmark/.gitignore @@ -0,0 +1,3 @@ +/rust/target/ +/official-fixtures/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/official-fixtures/Cargo.lock b/packages/automerge-benchmark/official-fixtures/Cargo.lock new file mode 100644 index 0000000000..7f51efbfe3 --- /dev/null +++ b/packages/automerge-benchmark/official-fixtures/Cargo.lock @@ -0,0 +1,1084 @@ +# 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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "automerge" +version = "0.10.0" +source = "git+https://github.com/automerge/automerge.git?rev=e4f9420a63b5ebfd079de7f22a852c2abb6e2774#e4f9420a63b5ebfd079de7f22a852c2abb6e2774" +dependencies = [ + "cfg-if", + "flate2", + "getrandom", + "hex", + "hexane", + "itertools", + "leb128", + "rand", + "rustc-hash", + "serde", + "sha2", + "smol_str", + "thiserror", + "tinyvec", + "tracing", + "unicode-segmentation", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "benchmark-battery" +version = "0.1.0" +source = "git+https://github.com/automerge/automerge.git?rev=e4f9420a63b5ebfd079de7f22a852c2abb6e2774#e4f9420a63b5ebfd079de7f22a852c2abb6e2774" +dependencies = [ + "anyhow", + "automerge", + "clap", + "getrandom", + "rand", + "resvg", + "serde", + "serde_json", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[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 = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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 = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[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 = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", +] + +[[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 = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexane" +version = "1.0.0-alpha.4" +source = "git+https://github.com/automerge/automerge.git?rev=e4f9420a63b5ebfd079de7f22a852c2abb6e2774#e4f9420a63b5ebfd079de7f22a852c2abb6e2774" +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 = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[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 = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "probo-automerge-official-fixtures" +version = "0.1.0" +dependencies = [ + "benchmark-battery", +] + +[[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 = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[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 = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[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 = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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 = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[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 = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[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-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.1", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/packages/automerge-benchmark/official-fixtures/Cargo.toml b/packages/automerge-benchmark/official-fixtures/Cargo.toml new file mode 100644 index 0000000000..3127cc49af --- /dev/null +++ b/packages/automerge-benchmark/official-fixtures/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "probo-automerge-official-fixtures" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +benchmark-battery = { git = "https://github.com/automerge/automerge.git", rev = "e4f9420a63b5ebfd079de7f22a852c2abb6e2774" } + +[workspace] diff --git a/packages/automerge-benchmark/official-fixtures/src/main.rs b/packages/automerge-benchmark/official-fixtures/src/main.rs new file mode 100644 index 0000000000..7492c9635d --- /dev/null +++ b/packages/automerge-benchmark/official-fixtures/src/main.rs @@ -0,0 +1,68 @@ +// 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::env; +use std::fs; +use std::path::Path; + +use benchmark_battery::{ + Automerge, big_paste_doc, deep_history_doc, list_splice_100, maps_in_maps_doc, + poorly_simulated_typing_doc, text_splice_100, +}; + +fn main() { + let output = env::args() + .nth(1) + .expect("usage: probo-automerge-official-fixtures OUTPUT_DIR"); + let output = Path::new(&output); + + fs::create_dir_all(output).expect("cannot create fixture directory"); + + write(output, "big-paste-100000.automerge", big_paste_doc(100_000)); + write( + output, + "text-splice-100-100000.automerge", + text_splice_100(100_000), + ); + write( + output, + "list-splice-100-100000.automerge", + list_splice_100(100_000), + ); + write( + output, + "typing-10000.automerge", + poorly_simulated_typing_doc(10_000), + ); + write( + output, + "deep-history-1000.automerge", + deep_history_doc(1_000), + ); + write( + output, + "maps-in-maps-1000.automerge", + maps_in_maps_doc(1_000), + ); +} + +fn write(output: &Path, name: &str, document: Automerge) { + fs::write(output.join(name), document.save()).expect("cannot write fixture"); +} 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/collaboration-interop-client.mjs b/packages/automerge-conformance/collaboration-interop-client.mjs new file mode 100644 index 0000000000..33d25c6dab --- /dev/null +++ b/packages/automerge-conformance/collaboration-interop-client.mjs @@ -0,0 +1,54 @@ +// 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. + +// A real automerge-repo client used as the interop oracle for the Go gateway. +// It connects to a Go WebSocket server that speaks the repo protocol, finds a +// document by URL, and prints the materialized document as JSON. It is driven by +// the Go test in pkg/automerge/collaboration. +// +// Usage: node collaboration-interop-client.mjs + +import { Repo } from "@automerge/automerge-repo"; +import { WebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import process from "node:process"; + +const [wsURL, documentURL] = process.argv.slice(2); + +if (!wsURL || !documentURL) { + process.stderr.write("usage: collaboration-interop-client.mjs \n"); + process.exit(2); +} + +const repo = new Repo({ + network: [new WebSocketClientAdapter(wsURL)], +}); + +try { + const handle = await repo.find(documentURL, { + signal: AbortSignal.timeout(10_000), + }); + + const doc = handle.doc(); + process.stdout.write(JSON.stringify(doc ?? null)); + process.exit(0); +} catch (error) { + process.stderr.write(`interop client failed: ${error?.message ?? error}\n`); + process.exit(1); +} diff --git a/packages/automerge-conformance/generate-collaboration-fixtures.mjs b/packages/automerge-conformance/generate-collaboration-fixtures.mjs new file mode 100644 index 0000000000..9ec604321e --- /dev/null +++ b/packages/automerge-conformance/generate-collaboration-fixtures.mjs @@ -0,0 +1,186 @@ +// 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. + +// Generates byte-exact automerge-repo protocol fixtures for the Go collaboration +// codec, using the pinned packages' own CBOR encoder so the bytes match what the +// JavaScript client puts on the wire. Run via +// `make generate-automerge-collaboration-fixtures`. + +import { cbor } from "@automerge/automerge-repo"; +import { Buffer } from "node:buffer"; + +// The presence envelope marker key (PRESENCE_MESSAGE_MARKER in the upstream +// source). It is a stable protocol constant; the package does not export the +// constants module, so it is inlined here and documented in PROTOCOL.md. +const PRESENCE_MESSAGE_MARKER = "__presence"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const outputDir = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "pkg", + "automerge", + "collaboration", + "testdata", +); + +mkdirSync(outputDir, { recursive: true }); + +const base64 = value => Buffer.from(value).toString("base64"); + +// Each presence message, wrapped in the __presence envelope exactly as +// Presence.broadcast does before DocHandle.broadcast CBOR-encodes it. +const presenceMessages = { + update: { type: "update", channel: "cursor", value: { anchor: "a", head: "b" } }, + snapshot: { type: "snapshot", state: { cursor: { anchor: "a", head: "b" } } }, + heartbeat: { type: "heartbeat" }, + goodbye: { type: "goodbye" }, +}; + +const fixtures = {}; + +for (const [name, message] of Object.entries(presenceMessages)) { + const envelope = { [PRESENCE_MESSAGE_MARKER]: message }; + const data = cbor.encode(envelope); + + fixtures[`presence-${name}`] = { + description: `Presence ${name} envelope CBOR-encoded as an ephemeral payload`, + marker: PRESENCE_MESSAGE_MARKER, + envelope, + cborBase64: base64(data), + }; + + // A full ephemeral repo message carrying this presence payload. + const ephemeral = { + type: "ephemeral", + senderId: "peer-a", + targetId: "peer-b", + documentId: "4NMNnkMhL2wRfvHYuG1uxN", + sessionId: "session-a", + count: 1, + data, + }; + + fixtures[`ephemeral-${name}`] = { + description: `Ephemeral repo message wrapping a presence ${name}`, + message: { ...ephemeral, data: base64(ephemeral.data) }, + payloadCborBase64: base64(data), + }; +} + +// A round-trip guard: CBOR that decodes back to the envelope it came from. +fixtures["presence-roundtrip"] = (() => { + const envelope = { + [PRESENCE_MESSAGE_MARKER]: { + type: "update", + channel: "cursor", + value: { anchor: "AAEC", head: "AwQF" }, + }, + }; + const data = cbor.encode(envelope); + const decoded = cbor.decode(data); + + return { + description: "CBOR round-trip of a presence update envelope", + envelope, + cborBase64: base64(data), + decodesEqual: JSON.stringify(decoded) === JSON.stringify(envelope), + }; +})(); + +// Transport-layer frames, encoded with the same repo CBOR helper the WebSocket +// adapter uses for every frame. Each frame is one CBOR-encoded message. +const peerMetadata = { isEphemeral: false }; + +const wireFrames = { + "wire-join": { + description: "Client join handshake frame", + message: { + type: "join", + senderId: "peer-a", + peerMetadata, + supportedProtocolVersions: ["1"], + }, + }, + "wire-peer": { + description: "Server peer handshake reply frame", + message: { + type: "peer", + senderId: "server", + targetId: "peer-a", + peerMetadata, + selectedProtocolVersion: "1", + }, + }, + "wire-error": { + description: "Server error frame before closing the socket", + message: { + type: "error", + senderId: "server", + targetId: "peer-a", + message: "unauthorized", + }, + }, + "wire-sync": { + description: "Framed sync message carrying opaque Automerge sync bytes", + message: { + type: "sync", + senderId: "peer-a", + targetId: "server", + documentId: "4NMNnkMhL2wRfvHYuG1uxN", + data: new Uint8Array([0, 1, 2, 3]), + }, + }, + "wire-ephemeral": { + description: "Framed ephemeral message carrying a presence heartbeat payload", + message: { + type: "ephemeral", + senderId: "peer-a", + targetId: "server", + documentId: "4NMNnkMhL2wRfvHYuG1uxN", + sessionId: "session-a", + count: 1, + data: cbor.encode({ [PRESENCE_MESSAGE_MARKER]: { type: "heartbeat" } }), + }, + }, +}; + +for (const [name, frame] of Object.entries(wireFrames)) { + const encoded = cbor.encode(frame.message); + const message = { ...frame.message }; + if (message.data instanceof Uint8Array) { + message.data = base64(message.data); + } + + fixtures[name] = { + description: frame.description, + message, + frameCborBase64: base64(encoded), + }; +} + +for (const [name, fixture] of Object.entries(fixtures)) { + const path = join(outputDir, `${name}.json`); + writeFileSync(path, `${JSON.stringify(fixture, null, 2)}\n`); + process.stdout.write(`wrote ${path}\n`); +} 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 new file mode 100644 index 0000000000..ad1636c1d3 --- /dev/null +++ b/packages/automerge-conformance/oracle.mjs @@ -0,0 +1,588 @@ +// 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 * as Automerge from "@automerge/automerge"; +import { Buffer } from "node:buffer"; +import process from "node:process"; + +const chunks = []; +for await (const chunk of process.stdin) { + chunks.push(chunk); +} + +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( + document, + { + message: request.message, + time: request.timestamp, + }, + draft => { + draft.body = ""; + Automerge.splice(draft, ["body"], 0, 0, request.text); + }, + ); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "createRichText": { + let document = Automerge.from({ body: "" }, { actor: request.actor }); + document = Automerge.change(document, draft => { + Automerge.updateSpans(draft, ["body"], [ + { + type: "block", + value: { + type: "heading", + parents: [], + isEmbed: false, + attrs: { level: 2 }, + }, + }, + { + type: "text", + value: "Policy", + marks: { strong: true }, + }, + ]); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "createChange": { + let document = Automerge.init({ actor: request.actor }); + document = Automerge.change( + document, + { + message: request.message, + time: request.timestamp, + }, + draft => { + draft.title = "Policy"; + }, + ); + const changes = Automerge.getAllChanges(document); + process.stdout.write( + JSON.stringify({ + change: Buffer.from(changes[0]).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + 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 }); + let right = Automerge.load(Automerge.save(base), { actor: request.actorC }); + left = Automerge.change(left, draft => { + Automerge.splice(draft, ["body"], 0, 0, "L"); + }); + right = Automerge.change(right, draft => { + Automerge.splice(draft, ["body"], 0, 0, "R"); + }); + const changes = [ + ...Automerge.getAllChanges(base), + ...Automerge.getChanges(base, left), + ...Automerge.getChanges(base, right), + ]; + const merged = Automerge.merge(left, right); + process.stdout.write( + JSON.stringify({ + body: merged.body, + changes: changes.map(change => Buffer.from(change).toString("base64")), + heads: Automerge.getHeads(merged), + }), + ); + break; + } + case "createSyncMessage": { + const document = Automerge.from( + { title: "Policy" }, + { actor: request.actor }, + ); + const [, message] = Automerge.generateSyncMessage( + document, + Automerge.initSyncState(), + ); + if (!message) throw new Error("expected an initial sync message"); + process.stdout.write( + JSON.stringify({ + sync: Buffer.from(message).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + break; + } + case "inspectChange": { + const change = Buffer.from(request.change, "base64"); + const decoded = Automerge.decodeChange(change); + const [document] = Automerge.applyChanges(Automerge.init(), [change]); + process.stdout.write( + JSON.stringify({ + body: document.title, + data: normalize(decoded), + message: decoded.message, + heads: [decoded.hash], + }), + ); + break; + } + case "createComplexRichText": { + let document = Automerge.from({ body: "" }, { actor: request.actor }); + document = Automerge.change(document, draft => { + Automerge.updateSpans(draft, ["body"], [ + { + type: "block", + value: { + type: "paragraph", + parents: [], + isEmbed: false, + attrs: {}, + }, + }, + { + type: "text", + value: "A", + marks: { strong: true, em: true }, + }, + { + type: "text", + value: "B", + marks: { em: true }, + }, + ]); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + 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 => { + Automerge.updateSpans(draft, ["body"], [ + tableBlock("table", []), + tableBlock("table-row", ["table"]), + tableBlock("table-header", ["table", "table-row"], { + colspan: 1, + rowspan: 1, + colwidth: null, + }), + { type: "text", value: "A" }, + tableBlock("table-cell", ["table", "table-row"], { + colspan: 1, + rowspan: 1, + colwidth: null, + }), + { type: "text", value: "B" }, + tableBlock("table-row", ["table"]), + tableBlock("table-cell", ["table", "table-row"], { + colspan: 1, + rowspan: 1, + colwidth: null, + }), + { type: "text", value: "C" }, + ]); + }); + process.stdout.write( + JSON.stringify({ + document: Buffer.from(Automerge.save(document)).toString("base64"), + heads: Automerge.getHeads(document), + }), + ); + 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( + JSON.stringify({ + body: document.body, + heads: Automerge.getHeads(document), + }), + ); + 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}`); +} + +function tableBlock(type, parents, attrs = {}) { + return { + type: "block", + value: { + type, + parents, + attrs, + isEmbed: false, + }, + }; +} + +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 new file mode 100644 index 0000000000..42b8d7faee --- /dev/null +++ b/packages/automerge-conformance/package.json @@ -0,0 +1,15 @@ +{ + "name": "@probo/automerge-conformance", + "version": "1.0.0", + "private": true, + "type": "module", + "license": "MIT", + "scripts": { + "check": "node --check oracle.mjs && node --check generate-parity-inventory.mjs && node --check generate-collaboration-fixtures.mjs && node --check collaboration-interop-client.mjs" + }, + "dependencies": { + "@automerge/automerge": "^3.4.0", + "@automerge/automerge-repo": "2.6.0-alpha.3", + "@automerge/automerge-repo-network-websocket": "2.6.0-alpha.3" + } +} 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/packages/coredata/package.json b/packages/coredata/package.json index 5e75eb28ab..5f752d92e8 100644 --- a/packages/coredata/package.json +++ b/packages/coredata/package.json @@ -4,7 +4,7 @@ "description": "", "main": "./src/index.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "keywords": [], diff --git a/packages/helpers/package.json b/packages/helpers/package.json index a967fd3900..5288e189dc 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -4,7 +4,7 @@ "description": "", "main": "./src/index.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "keywords": [], diff --git a/packages/hooks/package.json b/packages/hooks/package.json index f4b31966e8..7cf5860544 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -4,7 +4,7 @@ "description": "", "main": "./src/index.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "keywords": [], diff --git a/packages/i18n/duration.ts b/packages/i18n/duration.ts index 36577945b0..f9de6bf4b1 100644 --- a/packages/i18n/duration.ts +++ b/packages/i18n/duration.ts @@ -33,9 +33,12 @@ const DURATION_UNITS = [ export function humanizeSeconds( seconds: number | null, t: Translator, + storageType?: string, ): string { if (seconds === null || seconds <= 0) { - return ''; + return storageType === "LOCAL_STORAGE" + ? t("duration.persistent") + : t("duration.session"); } let remaining = seconds; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 15b4133f81..59ec48cbdb 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -14,7 +14,7 @@ "keywords": [], "license": "MIT", "scripts": { - "test": "vitest run" + "test": "vitest run --passWithNoTests" }, "dependencies": { "@probo/helpers": "1.0.0" diff --git a/packages/relay/package.json b/packages/relay/package.json index e8885ab6e7..74bbebe6a3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "./src/index.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "keywords": [], diff --git a/packages/routes/package.json b/packages/routes/package.json index 74768d627e..036f8fa710 100644 --- a/packages/routes/package.json +++ b/packages/routes/package.json @@ -4,7 +4,7 @@ "description": "", "main": "./src/index.ts", "scripts": { - "test": "vitest run", + "test": "vitest run --passWithNoTests", "test:watch": "vitest" }, "keywords": [], diff --git a/packages/ui/package.json b/packages/ui/package.json index bb51d7491b..c45c948da2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,10 +10,13 @@ "dev:v2": "storybook dev -p 6007 -c .storybook-v2 --no-open", "build:v2": "storybook build -c .storybook-v2", "check": "tsc --noEmit -p tsconfig.app.json", + "test": "vitest run --passWithNoTests", "icons": "bun run src/Atoms/Icons/generator.ts" }, "dependencies": { "@ariakit/react": "^0.4.35", + "@automerge/automerge": "^3.4.0", + "@automerge/prosemirror": "^0.2.0", "@base-ui/react": "^1.6.0", "@floating-ui/react": "^0.27.20", "@fontsource-variable/inter": "^5.3.0", @@ -30,6 +33,7 @@ "@radix-ui/react-scroll-area": "^1.2.18", "@radix-ui/react-select": "^2.3.7", "@radix-ui/react-tabs": "^1.1.21", + "@tiptap/core": "^3.29.2", "@tiptap/extension-table": "^3.29.2", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.29.2", diff --git a/packages/ui/src/RichEditor/AutomergeSyncPlugin.ts b/packages/ui/src/RichEditor/AutomergeSyncPlugin.ts new file mode 100644 index 0000000000..b3579530f2 --- /dev/null +++ b/packages/ui/src/RichEditor/AutomergeSyncPlugin.ts @@ -0,0 +1,267 @@ +// 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. + +// Adapted from @automerge/prosemirror's MIT-licensed sync plugin. The +// structural-leaf path deliberately bypasses its text-offset fast path. + +import * as Automerge from "@automerge/automerge"; +import type { + DocHandle, + SchemaAdapter, +} from "@automerge/prosemirror"; +import { patchesToTr } from "@automerge/prosemirror/dist/patchesToTr.js"; +import pmToAm from "@automerge/prosemirror/dist/pmToAm.js"; +import { + pmDocFromSpans, + pmNodeToSpans, +} from "@automerge/prosemirror/dist/traversal.js"; +import type { Fragment } from "@tiptap/pm/model"; +import { + type EditorState, + Plugin, + PluginKey, + type Transaction, +} from "@tiptap/pm/state"; + +import type { RichEditorAutomergeDocument } from "./collaboration"; +import { + collaborationDebug, + summarizeAutomergeSpans, + summarizeProseMirrorDocument, +} from "./collaborationDebug"; + +export const automergeSyncPluginKey = new PluginKey("automerge-sync"); + +export function createAutomergeSyncPlugin( + adapter: SchemaAdapter, + handle: DocHandle, + path: Automerge.Prop[], +): Plugin { + let ignoreTransaction = false; + + return new Plugin({ + key: automergeSyncPluginKey, + view: (view) => { + const onPatch = ({ + doc, + patches, + patchInfo, + }: { + doc: Automerge.Doc; + patches: Automerge.Patch[]; + patchInfo: Automerge.PatchInfo; + }) => { + if (ignoreTransaction) return; + + collaborationDebug("remote-patches", { + patchCount: patches.length, + patches: patches.map(patch => ({ + action: patch.action, + path: patch.path, + })), + heads: Automerge.getHeads(doc), + spans: summarizeAutomergeSpans(doc), + prosemirror: summarizeProseMirrorDocument(view.state.doc), + }); + + const transaction = hasHorizontalRule(view.state.doc) + ? reconcileAutomergeDocument(adapter, path, doc, view.state) + : patchesToTr({ + adapter, + path, + before: patchInfo.before, + after: doc, + patches, + state: view.state, + }); + ignoreTransaction = true; + view.dispatch(transaction); + ignoreTransaction = false; + collaborationDebug("remote-applied", { + stepCount: transaction.steps.length, + prosemirror: summarizeProseMirrorDocument(view.state.doc), + }); + }; + + handle.on("change", onPatch); + + return { + destroy() { + handle.off("change", onPatch); + }, + }; + }, + appendTransaction(transactions, _oldState, state) { + if (ignoreTransaction) return undefined; + + const changedTransactions = transactions.filter( + transaction => transaction.docChanged, + ); + if (changedTransactions.length === 0) return undefined; + + collaborationDebug("local-before", { + transactionCount: changedTransactions.length, + steps: changedTransactions.flatMap(transaction => + transaction.steps.map(step => step.constructor.name), + ), + heads: Automerge.getHeads(handle.doc()), + spans: summarizeAutomergeSpans(handle.doc()), + prosemirror: summarizeProseMirrorDocument(state.doc), + structuralLeaf: hasHorizontalRule(state.doc), + }); + + ignoreTransaction = true; + handle.change((document) => { + if ( + hasHorizontalRule(state.doc) + || hasMarkStep(changedTransactions) + ) { + Automerge.updateSpans( + document, + path, + pmNodeToSpans(adapter, state.doc), + adapter.updateSpansConfig(), + ); + } else { + for (const transaction of changedTransactions) { + const spans = Automerge.spans(document, path); + pmToAm( + adapter, + spans, + transaction.steps, + document, + transaction.docs[0], + path, + ); + } + } + }); + ignoreTransaction = false; + + collaborationDebug("local-after", { + heads: Automerge.getHeads(handle.doc()), + spans: summarizeAutomergeSpans(handle.doc()), + prosemirror: summarizeProseMirrorDocument(state.doc), + }); + + return undefined; + }, + }); +} + +export function reconcileAutomergeDocument( + adapter: SchemaAdapter, + path: Automerge.Prop[], + document: Automerge.Doc, + state: EditorState, +): Transaction { + const nextDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, path), + ); + const transaction = state.tr; + const change = findDocumentDiff(state.doc.content, nextDocument.content); + if (!change) return transaction; + + transaction.replace( + change.start, + change.endBefore, + nextDocument.slice(change.start, change.endAfter), + ); + transaction.setMeta("addToHistory", false); + return transaction; +} + +function hasHorizontalRule(document: { + descendants: ( + callback: (node: { type: { name: string } }) => boolean, + ) => void; +}): boolean { + let found = false; + document.descendants((node) => { + if (node.type.name === "horizontalRule") { + found = true; + return false; + } + + return true; + }); + return found; +} + +function hasMarkStep(transactions: Transaction[]): boolean { + return transactions.some(transaction => + transaction.steps.some((step) => { + const stepType = step.constructor.name; + return stepType === "AddMarkStep" || stepType === "RemoveMarkStep"; + }), + ); +} + +function findDocumentDiff( + before: Fragment, + after: Fragment, +): { + start: number; + endBefore: number; + endAfter: number; +} | null { + let start = before.findDiffStart(after); + if (start === null) return null; + + const end = before.findDiffEnd(after); + if (!end) return null; + + let endBefore = end.a; + let endAfter = end.b; + if (endBefore < start && before.size < after.size) { + if ( + start > 0 + && start < after.size + && isSurrogatePair(after.textBetween(start - 1, start + 1)) + ) { + start--; + } + endAfter = start + endAfter - endBefore; + endBefore = start; + } else if (endAfter < start) { + if ( + start > 0 + && start < before.size + && isSurrogatePair(before.textBetween(start - 1, start + 1)) + ) { + start--; + } + endBefore = start + endBefore - endAfter; + endAfter = start; + } + + return { start, endBefore, endAfter }; +} + +function isSurrogatePair(value: string): boolean { + if (value.length !== 2) return false; + const first = value.charCodeAt(0); + const second = value.charCodeAt(1); + return first >= 0xdc00 + && first <= 0xdfff + && second >= 0xd800 + && second <= 0xdbff; +} diff --git a/packages/ui/src/RichEditor/AutomergeTableStructureExtension.ts b/packages/ui/src/RichEditor/AutomergeTableStructureExtension.ts new file mode 100644 index 0000000000..63bedd746b --- /dev/null +++ b/packages/ui/src/RichEditor/AutomergeTableStructureExtension.ts @@ -0,0 +1,67 @@ +// 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 { Extension } from "@tiptap/core"; + +export const AutomergeTableStructureExtension = Extension.create({ + name: "automergeTableStructure", + + addGlobalAttributes() { + return [ + { + types: [ + "automergeUnknownBlock", + "blockquote", + "codeBlock", + "heading", + "listItem", + "paragraph", + ], + attributes: { + isAmgBlock: { + default: false, + rendered: false, + keepOnSplit: true, + }, + unknownAttrs: { + default: null, + rendered: false, + }, + }, + }, + { + types: [ + "horizontalRule", + "table", + "tableRow", + "tableCell", + "tableHeader", + ], + attributes: { + isAmgBlock: { + default: true, + rendered: false, + keepOnSplit: true, + }, + }, + }, + ]; + }, +}); diff --git a/packages/ui/src/RichEditor/AutomergeUnknownBlockExtension.ts b/packages/ui/src/RichEditor/AutomergeUnknownBlockExtension.ts new file mode 100644 index 0000000000..e713aaea01 --- /dev/null +++ b/packages/ui/src/RichEditor/AutomergeUnknownBlockExtension.ts @@ -0,0 +1,35 @@ +// 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 { Node } from "@tiptap/core"; + +export const AutomergeUnknownBlockExtension = Node.create({ + name: "automergeUnknownBlock", + group: "block", + content: "block+", + + parseHTML() { + return [{ tag: "div[data-automerge-unknown-block]" }]; + }, + + renderHTML() { + return ["div", { "data-automerge-unknown-block": "" }, 0]; + }, +}); diff --git a/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx index 59a5e3a493..7e972460b5 100644 --- a/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx @@ -27,7 +27,6 @@ import { import { type Editor } from "@tiptap/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { getSlashStorage } from "../_lib/getSlashStorage"; import { MenuButton } from "../MenuButton"; import { deactivateSlashCommand } from "../SlashCommandExtension"; @@ -63,7 +62,10 @@ type BlockMenuContentProps = { slashState: { active: boolean; query: string; from: number }; }; -export function BlockMenuContent({ editor, slashState }: BlockMenuContentProps) { +export function BlockMenuContent({ + editor, + slashState, +}: BlockMenuContentProps) { const [slashNav, setSlashNav] = useState({ index: 0, query: "" }); const slashDropdownRef = useRef(null); @@ -109,30 +111,20 @@ export function BlockMenuContent({ editor, slashState }: BlockMenuContentProps) }, [slashState.active, slashState.from, editor, slashMenuRefs]); const deactivateSlash = useCallback(() => { - if (!editor) return; - const s = getSlashStorage(editor); - if (s) deactivateSlashCommand(s); + deactivateSlashCommand(editor.view); setSlashNav({ index: 0, query: "" }); }, [editor]); const handleSlashAction = useCallback( (item: BlockItem) => { if (!slashState.active) return; - const { from } = slashState; - const cursorPos = editor.state.selection.from; try { - editor.chain() - .focus() - .deleteRange({ from, to: cursorPos }) - .run(); - + deactivateSlash(); item.action(editor.chain().focus()).run(); } catch { // Block may no longer be in the document } - - deactivateSlash(); }, [editor, slashState, deactivateSlash], ); diff --git a/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx index e405e8df8e..0cd20b012f 100644 --- a/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx @@ -5,7 +5,6 @@ import { PlusIcon } from "@phosphor-icons/react"; import { type Editor } from "@tiptap/react"; -import { getSlashStorage } from "../_lib/getSlashStorage"; import { useBlockTrigger } from "../_lib/useBlockTrigger"; import { activateSlashCommand } from "../SlashCommandExtension"; @@ -35,11 +34,9 @@ export function BlockMenuTrigger({ editor, hoveredBlock }: BlockMenuTriggerProps editor.chain() .focus() .setTextSelection(textPos) - .insertContent("/") .run(); - const s = getSlashStorage(editor); - if (s) activateSlashCommand(s, textPos); + activateSlashCommand(editor.view, textPos); return; } @@ -57,11 +54,9 @@ export function BlockMenuTrigger({ editor, hoveredBlock }: BlockMenuTriggerProps .focus() .insertContentAt(insertPos, { type: "paragraph" }) .setTextSelection(textPos) - .insertContent("/") .run(); - const s = getSlashStorage(editor); - if (s) activateSlashCommand(s, textPos); + activateSlashCommand(editor.view, textPos); } catch { // Block may no longer be in the document } diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 899b5e3cee..b2bd20eba0 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -18,22 +18,38 @@ import { Text } from "@tiptap/extension-text"; import { Underline } from "@tiptap/extension-underline"; import { Dropcursor, UndoRedo } from "@tiptap/extensions"; import { type Content, Editor, EditorContent, useEditor } from "@tiptap/react"; -import { type ComponentProps, useCallback, useEffect } from "react"; +import { type ComponentProps, useCallback, useEffect, useMemo } from "react"; import { tv } from "tailwind-variants"; +import { AutomergeTableStructureExtension } from "./AutomergeTableStructureExtension"; +import { AutomergeUnknownBlockExtension } from "./AutomergeUnknownBlockExtension"; import { BlockMenu } from "./BlockMenu/BlockMenu"; import { BubbleMenu } from "./BubbleMenu"; import { CodeBlockExtension } from "./CodeBlockExtension"; +import { + createRichEditorAutomergeDocument as createAutomergeDocument, + createRichEditorCollaborationExtension, + richEditorAutomergeContent, + supportsRichEditorCollaboration as supportsCollaboration, +} from "./collaboration"; import { LinkExtension } from "./LinkExtension"; import { MarkdownPasteExtension } from "./MarkdownPasteExtension"; import { OptionsMenu } from "./OptionsMenu/OptionsMenu"; import { PlaceholderExtension } from "./PlaceholderExtension"; +import { + createRichEditorPresenceExtension, + type RichEditorCollaborationHandle, +} from "./presence"; import { SlashCommandExtension } from "./SlashCommandExtension"; import { TableCellMenu } from "./TableCellMenu/TableCellMenu"; import { TableColumnMenu } from "./TableColumnMenu/TableColumnMenu"; import { TableRowMenu } from "./TableRowMenu/TableRowMenu"; import { TableSelectionOverlay } from "./TableSelectionOverlay"; +const tableExtension = TableKit.configure({ + table: { resizable: true }, +}); + const extensions = [ Document, Paragraph, @@ -60,12 +76,16 @@ const extensions = [ width: 2, }), UndoRedo, - TableKit.configure({ - table: { resizable: true }, - }), + tableExtension, + AutomergeUnknownBlockExtension, MarkdownPasteExtension, ]; +export const richEditorCollaborationExtensions = [ + ...extensions, + AutomergeTableStructureExtension, +]; + const richEditorVariants = tv({ base: ["relative flex-1 min-w-0 overflow-auto py-14 pr-8 bg-level-1 shadow-base"], variants: { @@ -80,18 +100,48 @@ type RichEditorProps = ComponentProps<"div"> & { content: string; disabled?: boolean; onChangeContent: (content: string) => void; + collaborationHandle?: RichEditorCollaborationHandle; }; export function RichEditor(props: RichEditorProps) { - const { className, content, disabled = false, onChangeContent, ...divProps } = props; + const { + className, + content, + disabled = false, + onChangeContent, + collaborationHandle, + ...divProps + } = props; const handleUpdate = useCallback( ({ editor }: { editor: Editor }) => { + if (collaborationHandle) return; + onChangeContent(JSON.stringify(editor.getJSON())); }, - [onChangeContent], + [collaborationHandle, onChangeContent], ); + const editorExtensions = useMemo( + () => collaborationHandle + ? [ + ...richEditorCollaborationExtensions, + createRichEditorCollaborationExtension( + collaborationHandle, + richEditorCollaborationExtensions, + ), + createRichEditorPresenceExtension(collaborationHandle), + ] + : extensions, + [collaborationHandle], + ); + const initialContent = collaborationHandle + ? richEditorAutomergeContent( + collaborationHandle, + richEditorCollaborationExtensions, + ) + : (content ? JSON.parse(content) : "") as Content; + const editor = useEditor({ editorProps: { attributes: { @@ -99,10 +149,16 @@ export function RichEditor(props: RichEditorProps) { }, }, editable: !disabled, - extensions, - content: (content ? JSON.parse(content) : "") as Content, + extensions: editorExtensions, + content: initialContent, onUpdate: handleUpdate, - }); + onSelectionUpdate: ({ editor }) => { + collaborationHandle?.updatePresence?.( + editor.state.selection.anchor, + editor.state.selection.head, + ); + }, + }, [collaborationHandle]); useEffect(() => { if (!editor) return; @@ -130,3 +186,13 @@ export function RichEditor(props: RichEditorProps) { ); } + +export function createRichEditorAutomergeDocument( + content: string, +) { + return createAutomergeDocument(content, richEditorCollaborationExtensions); +} + +export function supportsRichEditorCollaboration(content: string): boolean { + return supportsCollaboration(content); +} diff --git a/packages/ui/src/RichEditor/SlashCommandExtension.test.ts b/packages/ui/src/RichEditor/SlashCommandExtension.test.ts new file mode 100644 index 0000000000..1703ba2822 --- /dev/null +++ b/packages/ui/src/RichEditor/SlashCommandExtension.test.ts @@ -0,0 +1,127 @@ +// 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 { Schema } from "@tiptap/pm/model"; +import { EditorState, type Transaction } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { describe, expect, it } from "vitest"; + +import { + createSlashCommandPlugin, + type SlashCommandStorage, +} from "./SlashCommandExtension"; + +const schema = new Schema({ + nodes: { + doc: { content: "block+" }, + paragraph: { content: "inline*", group: "block" }, + text: { group: "inline" }, + }, +}); + +describe("SlashCommandExtension", () => { + it("keeps slash queries out of the document", () => { + const storage = slashCommandStorage(); + const plugin = createSlashCommandPlugin(storage); + let state = EditorState.create({ + schema, + doc: schema.node("doc", null, [schema.node("paragraph")]), + plugins: [plugin], + }); + const transactions: Transaction[] = []; + const view = { + get state() { + return state; + }, + dispatch(transaction: Transaction) { + transactions.push(transaction); + state = state.apply(transaction); + }, + } as EditorView; + const handleTextInput = plugin.props.handleTextInput; + if (!handleTextInput) throw new Error("expected text input handler"); + + expect( + handleTextInput.call(plugin, view, 1, 1, "/", () => state.tr), + ).toBe(true); + expect( + handleTextInput.call(plugin, view, 1, 1, "head", () => state.tr), + ).toBe(true); + + expect(storage).toEqual({ + active: true, + query: "head", + from: 1, + }); + expect(state.doc.textContent).toBe(""); + expect(transactions.every(transaction => !transaction.docChanged)).toBe(true); + }); + + it("edits and closes a local query with keyboard commands", () => { + const storage = slashCommandStorage(); + const plugin = createSlashCommandPlugin(storage); + let state = EditorState.create({ + schema, + doc: schema.node("doc", null, [schema.node("paragraph")]), + plugins: [plugin], + }); + const view = { + get state() { + return state; + }, + dispatch(transaction: Transaction) { + state = state.apply(transaction); + }, + } as EditorView; + const handleTextInput = plugin.props.handleTextInput; + const handleKeyDown = plugin.props.handleKeyDown; + if (!handleTextInput || !handleKeyDown) { + throw new Error("expected slash command keyboard handlers"); + } + + handleTextInput.call(plugin, view, 1, 1, "/", () => state.tr); + handleTextInput.call(plugin, view, 1, 1, "ab", () => state.tr); + expect( + handleKeyDown.call( + plugin, + view, + { key: "Backspace" } as KeyboardEvent, + ), + ).toBe(true); + expect(storage.query).toBe("a"); + expect( + handleKeyDown.call( + plugin, + view, + { key: "Escape" } as KeyboardEvent, + ), + ).toBe(true); + expect(storage.active).toBe(false); + expect(state.doc.textContent).toBe(""); + }); +}); + +function slashCommandStorage(): SlashCommandStorage { + return { + active: false, + query: "", + from: 0, + }; +} diff --git a/packages/ui/src/RichEditor/SlashCommandExtension.ts b/packages/ui/src/RichEditor/SlashCommandExtension.ts index 981b83ca82..4ef82c5838 100644 --- a/packages/ui/src/RichEditor/SlashCommandExtension.ts +++ b/packages/ui/src/RichEditor/SlashCommandExtension.ts @@ -3,10 +3,32 @@ // that can be found in the LICENSE file. import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { + Plugin, + PluginKey, + type Transaction, +} from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -const slashCommandKey = new PluginKey("slashCommand"); +type SlashCommandPluginState = { + active: boolean; + query: string; + from: number; +}; + +type SlashCommandAction + = { type: "open"; from: number } + | { type: "append"; text: string } + | { type: "backspace" } + | { type: "close" }; + +const slashCommandKey = new PluginKey("slashCommand"); +const inactiveSlashCommand: SlashCommandPluginState = { + active: false, + query: "", + from: 0, +}; export type SlashCommandStorage = { active: boolean; @@ -14,16 +36,12 @@ export type SlashCommandStorage = { from: number; }; -export function activateSlashCommand(storage: SlashCommandStorage, from: number) { - storage.active = true; - storage.query = ""; - storage.from = from; +export function activateSlashCommand(view: EditorView, from: number): void { + dispatchSlashCommand(view, { type: "open", from }); } -export function deactivateSlashCommand(storage: SlashCommandStorage) { - storage.active = false; - storage.query = ""; - storage.from = 0; +export function deactivateSlashCommand(view: EditorView): void { + dispatchSlashCommand(view, { type: "close" }); } export const SlashCommandExtension = Extension.create({ @@ -38,108 +56,164 @@ export const SlashCommandExtension = Extension.create m.type.name === "code")) return false; - - const blockStart = $from.start($from.depth); - if (from !== blockStart) return false; - if ($from.parent.textContent.length !== 0) return false; - - storage.active = true; - storage.from = from; - storage.query = ""; - - return false; - }, - - handleKeyDown(view, event) { - if (!storage.active) return false; - - if (event.key === "Escape") { - const { state } = view; - const cursorPos = state.selection.from; - const from = storage.from; - - deactivateSlashCommand(storage); - - if (cursorPos > from) { - const tr = state.tr.delete(from, cursorPos); - view.dispatch(tr); - } - - return true; - } - - if (event.key === "Backspace") { - const { state } = view; - const cursorPos = state.selection.from; - - if (cursorPos <= storage.from + 1) { - deactivateSlashCommand(storage); - } - } - - return false; - }, - - decorations(state) { - if (!storage.active) return DecorationSet.empty; - - const { from } = storage; - const cursorPos = state.selection.from; - - try { - const $from = state.doc.resolve(from); - const blockStart = $from.start($from.depth); - const blockEnd = $from.end($from.depth); - - if (cursorPos < blockStart || cursorPos > blockEnd) { - deactivateSlashCommand(storage); - return DecorationSet.empty; - } - - const text = state.doc.textBetween(from, cursorPos); - if (!text.startsWith("/")) { - deactivateSlashCommand(storage); - return DecorationSet.empty; - } - - storage.query = text.slice(1); - - const decoEnd = Math.max(cursorPos, from + 1); - const isEmpty = storage.query.length === 0; - - return DecorationSet.create(state.doc, [ - Decoration.inline(from, decoEnd, { - "nodeName": "span", - "class": "slash-search", - "data-placeholder": "Search", - "data-empty": isEmpty - ? "true" - : "false", - }), - ]); - } catch { - deactivateSlashCommand(storage); - return DecorationSet.empty; - } - }, - }, - }), - ]; + return [createSlashCommandPlugin(this.storage)]; }, }); + +export function createSlashCommandPlugin( + storage: SlashCommandStorage, +): Plugin { + return new Plugin({ + key: slashCommandKey, + + state: { + init() { + updateSlashCommandStorage(storage, inactiveSlashCommand); + return inactiveSlashCommand; + }, + + apply(transaction, current) { + const action = slashCommandAction(transaction); + let next = applySlashCommandAction(current, action); + if (next.active && transaction.docChanged) { + const mapped = transaction.mapping.mapResult(next.from, 1); + next = mapped.deleted + ? inactiveSlashCommand + : { ...next, from: mapped.pos }; + } + if ( + next.active + && transaction.selectionSet + && transaction.selection.from !== next.from + ) { + next = inactiveSlashCommand; + } + + updateSlashCommandStorage(storage, next); + return next; + }, + }, + + props: { + handleTextInput(view, from, _to, text) { + const current = slashCommandKey.getState(view.state); + if (current?.active) { + dispatchSlashCommand(view, { type: "append", text }); + return true; + } + if (text !== "/" || !canOpenSlashCommand(view, from)) { + return false; + } + + dispatchSlashCommand(view, { type: "open", from }); + return true; + }, + + handleKeyDown(view, event) { + const current = slashCommandKey.getState(view.state); + if (!current?.active) return false; + + if (event.key === "Escape") { + dispatchSlashCommand(view, { type: "close" }); + return true; + } + + if (event.key === "Backspace") { + dispatchSlashCommand(view, { type: "backspace" }); + return true; + } + + return false; + }, + + decorations(state) { + const current = slashCommandKey.getState(state); + if (!current?.active) return DecorationSet.empty; + + try { + const $from = state.doc.resolve(current.from); + const decorations = [ + Decoration.widget( + current.from, + () => createSlashCommandWidget(current.query), + { key: `slash-command:${current.query}`, side: -1 }, + ), + ]; + if ($from.depth > 0 && $from.parent.isTextblock) { + decorations.push( + Decoration.node( + $from.before($from.depth), + $from.after($from.depth), + { class: "slash-command-active" }, + ), + ); + } + return DecorationSet.create(state.doc, decorations); + } catch { + return DecorationSet.empty; + } + }, + }, + }); +} + +function applySlashCommandAction( + current: SlashCommandPluginState, + action: SlashCommandAction | undefined, +): SlashCommandPluginState { + switch (action?.type) { + case "open": + return { active: true, query: "", from: action.from }; + case "append": + return { ...current, query: current.query + action.text }; + case "backspace": + return current.query.length === 0 + ? inactiveSlashCommand + : { ...current, query: current.query.slice(0, -1) }; + case "close": + return inactiveSlashCommand; + default: + return current; + } +} + +function canOpenSlashCommand(view: EditorView, from: number): boolean { + const $from = view.state.doc.resolve(from); + if ($from.parent.type.name === "codeBlock") return false; + if ($from.marks().some(mark => mark.type.name === "code")) return false; + + const blockStart = $from.start($from.depth); + return from === blockStart && $from.parent.textContent.length === 0; +} + +function createSlashCommandWidget(query: string): HTMLElement { + const widget = document.createElement("span"); + widget.className = "slash-search"; + widget.dataset.placeholder = "Search"; + widget.dataset.empty = query.length === 0 ? "true" : "false"; + widget.contentEditable = "false"; + widget.textContent = `/${query}`; + return widget; +} + +function dispatchSlashCommand( + view: EditorView, + action: SlashCommandAction, +): void { + view.dispatch(view.state.tr.setMeta(slashCommandKey, action)); +} + +function slashCommandAction( + transaction: Transaction, +): SlashCommandAction | undefined { + return transaction.getMeta(slashCommandKey) as SlashCommandAction | undefined; +} + +function updateSlashCommandStorage( + storage: SlashCommandStorage, + state: SlashCommandPluginState, +): void { + storage.active = state.active; + storage.query = state.query; + storage.from = state.from; +} diff --git a/packages/ui/src/RichEditor/collaboration.test.ts b/packages/ui/src/RichEditor/collaboration.test.ts new file mode 100644 index 0000000000..701a00e82a --- /dev/null +++ b/packages/ui/src/RichEditor/collaboration.test.ts @@ -0,0 +1,825 @@ +// 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 * as Automerge from "@automerge/automerge"; +import { + type DocHandle, + pmDocFromSpans, + pmNodeToSpans, + type SchemaAdapter, +} from "@automerge/prosemirror"; +import { getSchema } from "@tiptap/core"; +import { history, undo } from "@tiptap/pm/history"; +import { Fragment } from "@tiptap/pm/model"; +import { EditorState, type Transaction } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; + +import { + createAutomergeSyncPlugin, + reconcileAutomergeDocument, +} from "./AutomergeSyncPlugin"; +import { + createSchemaAdapter, + explicitBlockIdentityPlugin, + richEditorAutomergeContent, + type RichEditorAutomergeDocument, +} from "./collaboration"; +import { + createRichEditorAutomergeDocument, + richEditorCollaborationExtensions, + supportsRichEditorCollaboration, +} from "./RichEditor"; + +function syncPlugin({ + adapter, + handle, + path, +}: { + adapter: SchemaAdapter; + handle: DocHandle; + path: Automerge.Prop[]; +}) { + return createAutomergeSyncPlugin(adapter, handle, path); +} + +describe("RichEditor collaboration", () => { + it("imports supported ProseMirror content into Automerge rich text", () => { + const document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Policy" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Hello ", marks: [{ type: "bold" }] }, + { type: "text", text: "world" }, + ], + }, + ], + }), + ); + + expect(Automerge.spans(document, ["body"])).not.toHaveLength(0); + expect(document.body).toContain("Hello world"); + }); + + it("preserves table and row boundaries", () => { + const content = tableDocumentJSON(); + + expect(supportsRichEditorCollaboration(content)).toBe(true); + const document = createRichEditorAutomergeDocument(content); + const spans = Automerge.spans(document, ["body"]); + expect( + spans + .filter(span => span.type === "block") + .map(span => Automerge.isImmutableString(span.value.type) + ? span.value.type.val + : span.value.type), + ).toEqual([ + "table", + "table-row", + "table-cell", + "table-cell", + "table-row", + "table-cell", + "table-cell", + ]); + + const handle: DocHandle = { + doc: () => document, + change: () => {}, + on: () => {}, + off: () => {}, + }; + const roundTrip = richEditorAutomergeContent( + handle, + richEditorCollaborationExtensions, + ) as { + content: Array<{ + type: string; + content: Array<{ + type: string; + content: unknown[]; + }>; + }>; + }; + expect(roundTrip.content[0].type).toBe("table"); + expect(roundTrip.content[0].content).toHaveLength(2); + expect(roundTrip.content[0].content[0].type).toBe("tableRow"); + expect(roundTrip.content[0].content[0].content).toHaveLength(2); + }); + + it("applies table cell edits through the Automerge sync plugin", () => { + let document = createRichEditorAutomergeDocument(tableDocumentJSON()); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const pmDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + const state = EditorState.create({ + schema: adapter.schema, + doc: pmDocument, + plugins: [ + syncPlugin({ + adapter, + handle, + path: ["body"], + }), + ], + }); + let textPosition: number | undefined; + state.doc.descendants((node, position) => { + if (node.isText && node.text === "A") { + textPosition = position; + return false; + } + + return true; + }); + expect(textPosition).toBeDefined(); + + state.applyTransaction(state.tr.insertText("X", textPosition! + 1)); + + expect( + Automerge.spans(document, ["body"]) + .filter(span => span.type === "text") + .map(span => span.value) + .join(""), + ).toContain("AX"); + }); + + it("maps live mark steps to Automerge mark names", () => { + let document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "text", text: "Alpha Beta" }], + }], + }), + ); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + let state = EditorState.create({ + schema: adapter.schema, + doc: pmDocFromSpans(adapter, Automerge.spans(document, ["body"])), + plugins: [createAutomergeSyncPlugin(adapter, handle, ["body"])], + }); + const position = findProseMirrorTextPosition(state, "Alpha Beta"); + + state = state.applyTransaction( + state.tr.addMark( + position, + position + 10, + adapter.schema.marks.bold.create(), + ), + ).state; + state = state.applyTransaction( + state.tr.addMark( + position + 6, + position + 10, + adapter.schema.marks.italic.create(), + ), + ).state; + state = state.applyTransaction( + state.tr.removeMark( + position + 2, + position + 7, + adapter.schema.marks.bold, + ), + ).state; + + const spans = Automerge.spans(document, ["body"]); + const markNames = new Set( + spans.flatMap(span => + span.type === "text" ? Object.keys(span.marks ?? {}) : [], + ), + ); + expect(markNames).toContain("strong"); + expect(markNames).toContain("em"); + expect(markNames).not.toContain("bold"); + expect(markNames).not.toContain("italic"); + expect(pmDocFromSpans(adapter, spans).eq(state.doc)).toBe(true); + }); + + it("preserves rows inserted through ProseMirror transactions", () => { + let document = createRichEditorAutomergeDocument(tableDocumentJSON()); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const pmDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + const state = EditorState.create({ + schema: adapter.schema, + doc: pmDocument, + plugins: [ + syncPlugin({ + adapter, + handle, + path: ["body"], + }), + ], + }); + const paragraph = adapter.schema.nodes.paragraph.create( + null, + adapter.schema.text("E"), + ); + const cell = adapter.schema.nodes.tableCell.create( + { + isAmgBlock: true, + colspan: 1, + rowspan: 1, + colwidth: null, + }, + paragraph, + ); + const row = adapter.schema.nodes.tableRow.create( + { isAmgBlock: true }, + [cell, cell], + ); + const table = state.doc.firstChild; + if (!table) throw new Error("expected table node"); + + state.applyTransaction( + state.tr.insert(table.nodeSize - 1, row), + ); + + const spans = Automerge.spans(document, ["body"]); + expect( + spans.filter(span => + span.type === "block" + && Automerge.isImmutableString(span.value.type) + && span.value.type.val === "table-row", + ), + ).toHaveLength(3); + + const roundTrip = pmDocFromSpans(adapter, spans); + expect(roundTrip.firstChild?.childCount).toBe(3); + }); + + it("converges concurrent row insertions", () => { + const base = createRichEditorAutomergeDocument(tableDocumentJSON()); + const left = insertTableRow( + Automerge.clone(base, { actor: "01000000000000000000000000000000" }), + "L", + ); + const right = insertTableRow( + Automerge.clone(base, { actor: "02000000000000000000000000000000" }), + "R", + ); + const merged = Automerge.merge(left, right); + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const spans = Automerge.spans(merged, ["body"]); + const pmDocument = pmDocFromSpans(adapter, spans); + + expect(pmDocument.firstChild?.childCount).toBe(4); + expect(pmDocument.textContent).toContain("L"); + expect(pmDocument.textContent).toContain("R"); + }); + + it("records local table edits in collaborative undo history", () => { + let document = createRichEditorAutomergeDocument(tableDocumentJSON()); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + let state = EditorState.create({ + schema: adapter.schema, + doc: pmDocFromSpans(adapter, Automerge.spans(document, ["body"])), + plugins: [ + history(), + syncPlugin({ + adapter, + handle, + path: ["body"], + }), + ], + }); + let textPosition: number | undefined; + state.doc.descendants((node, position) => { + if (node.isText && node.text === "A") { + textPosition = position; + return false; + } + + return true; + }); + if (textPosition === undefined) throw new Error("expected cell text"); + + state = state.applyTransaction( + state.tr.insertText("X", textPosition + 1), + ).state; + expect(document.body).toContain("AX"); + + let undoTransaction: Transaction | undefined; + expect( + undo(state, (transaction) => { + undoTransaction = transaction; + }), + ).toBe(true); + if (!undoTransaction) throw new Error("expected undo transaction"); + + state.applyTransaction(undoTransaction); + expect(document.body).not.toContain("AX"); + }); + + it("inserts a divider as a structural block", () => { + let document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "A" }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "B" }], + }, + ], + }), + ); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const pmDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + const state = EditorState.create({ + schema: adapter.schema, + doc: pmDocument, + plugins: [ + syncPlugin({ + adapter, + handle, + path: ["body"], + }), + ], + }); + const firstParagraph = state.doc.firstChild; + if (!firstParagraph) throw new Error("expected first paragraph"); + + state.applyTransaction( + state.tr.insert( + firstParagraph.nodeSize, + adapter.schema.nodes.horizontalRule.create({ isAmgBlock: true }), + ), + ); + + const spans = Automerge.spans(document, ["body"]); + expect( + spans.some(span => + span.type === "block" + && Automerge.isImmutableString(span.value.type) + && span.value.type.val === "horizontal-rule", + ), + ).toBe(true); + expect( + pmDocFromSpans(adapter, spans).toJSON(), + ).toMatchObject({ + content: [ + { type: "paragraph" }, + { type: "horizontalRule" }, + { type: "paragraph" }, + ], + }); + }); + + it("synchronizes text typed after a newly inserted divider", () => { + let document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before" }], + }, + ], + }), + ); + const targetSchema = getSchema(richEditorCollaborationExtensions); + const adapter = createSchemaAdapter( + richEditorCollaborationExtensions, + targetSchema, + ); + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const initialAdapter = createSchemaAdapter( + richEditorCollaborationExtensions, + ); + const pmDocument = targetSchema.nodeFromJSON( + pmDocFromSpans( + initialAdapter, + Automerge.spans(document, ["body"]), + ).toJSON(), + ); + let state = EditorState.create({ + schema: targetSchema, + doc: pmDocument, + plugins: [ + explicitBlockIdentityPlugin(), + createAutomergeSyncPlugin(adapter, handle, ["body"]), + ], + }); + const divider = targetSchema.nodes.horizontalRule.create(); + const paragraph = targetSchema.nodes.paragraph.create(); + state = state.applyTransaction( + state.tr.insert( + state.doc.content.size, + Fragment.fromArray([divider, paragraph]), + ), + ).state; + expect( + Automerge.spans(document, ["body"]) + .filter(span => span.type === "block") + .map(span => Automerge.isImmutableString(span.value.type) + ? span.value.type.val + : span.value.type), + ).toEqual([ + "paragraph", + "horizontal-rule", + "paragraph", + ]); + expect(state.doc.lastChild?.attrs.isAmgBlock).toBe(true); + + const lastParagraphPosition = state.doc.content.size - paragraph.nodeSize; + let insertionError: unknown; + try { + state = state.applyTransaction( + state.tr.insertText("After", lastParagraphPosition + 1), + ).state; + } catch (error) { + insertionError = error; + } + + const spans = Automerge.spans(document, ["body"]); + expect( + spans + .filter(span => span.type === "block") + .map(span => Automerge.isImmutableString(span.value.type) + ? span.value.type.val + : span.value.type), + ).toEqual([ + "paragraph", + "horizontal-rule", + "paragraph", + ]); + expect( + spans + .filter(span => span.type === "block") + .map(span => ({ + type: automergeString(span.value.type), + isEmbed: span.value.isEmbed, + parents: automergeStringArray(span.value.parents), + })), + ).toEqual([ + { type: "paragraph", isEmbed: false, parents: [] }, + { type: "horizontal-rule", isEmbed: false, parents: [] }, + { type: "paragraph", isEmbed: false, parents: [] }, + ]); + expect( + spans + .filter(span => span.type === "text") + .map(span => span.value) + .join(""), + ).toBe("BeforeAfter"); + expect( + spans.map(span => span.type === "text" + ? `text:${span.value}` + : `block:${automergeString(span.value.type)}`), + ).toEqual([ + "block:paragraph", + "text:Before", + "block:horizontal-rule", + "block:paragraph", + "text:After", + ]); + expect(insertionError).toBeUndefined(); + + const remoteDocument = pmDocFromSpans(initialAdapter, spans); + expect(remoteDocument.textContent).toBe("BeforeAfter"); + expect(remoteDocument.child(1).type.name).toBe("horizontalRule"); + expect(remoteDocument.child(2).textContent).toBe("After"); + + const nextParagraph = targetSchema.nodes.paragraph.create(); + state = state.applyTransaction( + state.tr.insert(state.doc.content.size, nextParagraph), + ).state; + const nextPosition = state.doc.content.size - nextParagraph.nodeSize + 1; + state.applyTransaction(state.tr.insertText("Later", nextPosition)); + + const laterSpans = Automerge.spans(document, ["body"]); + expect( + laterSpans.map(span => span.type === "text" + ? `text:${span.value}` + : `block:${automergeString(span.value.type)}`), + ).toEqual([ + "block:paragraph", + "text:Before", + "block:horizontal-rule", + "block:paragraph", + "text:After", + "block:paragraph", + "text:Later", + ]); + const laterRemote = pmDocFromSpans(initialAdapter, laterSpans); + expect(laterRemote.textContent).toBe("BeforeAfterLater"); + expect(laterRemote.child(3).textContent).toBe("Later"); + }); + + it("reconciles remote text between empty structural blocks", () => { + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + let document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Before" }], + }, + { type: "horizontalRule" }, + { type: "paragraph" }, + { type: "paragraph" }, + { + type: "paragraph", + content: [{ type: "text", text: "After" }], + }, + ], + }), + ); + const initialDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + const state = EditorState.create({ + schema: adapter.schema, + doc: initialDocument, + }); + const emptyParagraphPositions: number[] = []; + state.doc.descendants((node, position) => { + if (node.type.name === "paragraph" && node.content.size === 0) { + emptyParagraphPositions.push(position); + } + }); + expect(emptyParagraphPositions).toHaveLength(2); + + const changedDocument = state.tr.insertText( + "X", + emptyParagraphPositions[0] + 1, + ).doc; + document = Automerge.change(document, (draft) => { + Automerge.updateSpans( + draft, + ["body"], + pmNodeToSpans(adapter, changedDocument), + adapter.updateSpansConfig(), + ); + }); + + const transaction = reconcileAutomergeDocument( + adapter, + ["body"], + document, + state, + ); + const reconciled = state.apply(transaction); + expect(reconciled.doc.eq(changedDocument)).toBe(true); + }); + + it("preserves Mermaid code-block language", () => { + const document = createRichEditorAutomergeDocument( + JSON.stringify({ + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { language: "mermaid" }, + content: [{ type: "text", text: "graph TD; A-->B" }], + }, + ], + }), + ); + const spans = Automerge.spans(document, ["body"]); + const block = spans.find(span => span.type === "block"); + if (block?.type !== "block") throw new Error("expected block span"); + const attrs = block.value.attrs; + if ( + attrs === null + || typeof attrs !== "object" + || !("language" in attrs) + ) { + throw new Error("expected code-block language"); + } + expect(attrs.language).toBe("mermaid"); + + const handle: DocHandle = { + doc: () => document, + change: () => {}, + on: () => {}, + off: () => {}, + }; + const roundTrip = richEditorAutomergeContent( + handle, + richEditorCollaborationExtensions, + ) as { + content: Array<{ + type: string; + attrs: { language: string | null }; + }>; + }; + expect(roundTrip.content[0]).toMatchObject({ + type: "codeBlock", + attrs: { language: "mermaid" }, + }); + }); +}); + +function findProseMirrorTextPosition( + state: EditorState, + text: string, +): number { + let position: number | undefined; + state.doc.descendants((node, at) => { + if (node.isText && node.text === text) { + position = at; + return false; + } + + return true; + }); + if (position === undefined) throw new Error(`expected text ${text}`); + + return position; +} + +function tableDocumentJSON(): string { + return JSON.stringify({ + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tableRow", + content: [ + tableCell("A"), + tableCell("B"), + ], + }, + { + type: "tableRow", + content: [ + tableCell("C"), + tableCell("D"), + ], + }, + ], + }, + ], + }); +} + +function automergeString(value: unknown): string { + if (typeof value === "string") return value; + if (Automerge.isImmutableString(value)) return value.val; + throw new Error("expected Automerge string"); +} + +function automergeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) throw new Error("expected Automerge string array"); + return value.map(automergeString); +} + +function tableCell(text: string) { + return { + type: "tableCell", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text }], + }, + ], + }; +} + +function insertTableRow( + initial: Automerge.Doc, + text: string, +): Automerge.Doc { + let document = initial; + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const pmDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + const state = EditorState.create({ + schema: adapter.schema, + doc: pmDocument, + plugins: [ + syncPlugin({ + adapter, + handle, + path: ["body"], + }), + ], + }); + const paragraph = adapter.schema.nodes.paragraph.create( + null, + adapter.schema.text(text), + ); + const cell = adapter.schema.nodes.tableCell.create( + { + isAmgBlock: true, + colspan: 1, + rowspan: 1, + colwidth: null, + }, + paragraph, + ); + const row = adapter.schema.nodes.tableRow.create( + { isAmgBlock: true }, + [cell], + ); + const table = state.doc.firstChild; + if (!table) throw new Error("expected table node"); + + state.applyTransaction(state.tr.insert(table.nodeSize - 1, row)); + + return document; +} diff --git a/packages/ui/src/RichEditor/collaboration.ts b/packages/ui/src/RichEditor/collaboration.ts new file mode 100644 index 0000000000..5a691c10a2 --- /dev/null +++ b/packages/ui/src/RichEditor/collaboration.ts @@ -0,0 +1,439 @@ +// 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 * as Automerge from "@automerge/automerge"; +import { + type DocHandle, + type MappedMarkSpec, + type MappedNodeSpec, + type MappedSchemaSpec, + pmDocFromSpans, + pmNodeToSpans, + SchemaAdapter, +} from "@automerge/prosemirror"; +import { Extension, type Extensions, getSchema } from "@tiptap/core"; +import type { + Mark, + Node as ProseMirrorNode, + Schema, +} from "@tiptap/pm/model"; +import { Plugin } from "@tiptap/pm/state"; + +import { createAutomergeSyncPlugin } from "./AutomergeSyncPlugin"; + +export type RichEditorAutomergeDocument = { + body: string; +}; + +const textPath: Automerge.Prop[] = ["body"]; + +const supportedNodeNames = new Set([ + "doc", + "paragraph", + "text", + "heading", + "blockquote", + "codeBlock", + "horizontalRule", + "hardBreak", + "bulletList", + "orderedList", + "listItem", + "table", + "tableRow", + "tableCell", + "tableHeader", +]); + +const supportedMarkNames = new Set([ + "bold", + "italic", + "strike", + "underline", + "code", + "link", +]); + +export function supportsRichEditorCollaboration(content: string): boolean { + if (!content) return true; + + const document = JSON.parse(content) as { + type?: string; + marks?: Array<{ type?: string }>; + content?: unknown[]; + }; + + function supportsNode(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + + const node = value as { + type?: string; + marks?: Array<{ type?: string }>; + content?: unknown[]; + }; + if (!node.type || !supportedNodeNames.has(node.type)) return false; + if (node.marks?.some(mark => !mark.type || !supportedMarkNames.has(mark.type))) { + return false; + } + return node.content?.every(supportsNode) ?? true; + } + + return supportsNode(document); +} + +export function createRichEditorAutomergeDocument( + content: string, + extensions: Extensions, +): Automerge.Doc { + const adapter = createSchemaAdapter(extensions); + const documentJSON: Record = content + ? parseJSONObject(content) + : { type: "doc", content: [{ type: "paragraph" }] }; + markAutomergeStructuralBlocks(documentJSON); + const pmDocument = adapter.schema.nodeFromJSON(documentJSON); + const spans = pmNodeToSpans(adapter, pmDocument); + const document = Automerge.from({ body: "" }); + + return Automerge.change(document, (draft) => { + Automerge.updateSpans(draft, textPath, spans, adapter.updateSpansConfig()); + }); +} + +export function richEditorAutomergeContent( + handle: DocHandle, + extensions: Extensions, +): object { + const adapter = createSchemaAdapter(extensions); + const content: unknown = pmDocFromSpans( + adapter, + Automerge.spans(handle.doc(), textPath), + ).toJSON(); + if (!isJSONObject(content)) { + throw new Error("Automerge produced invalid ProseMirror content"); + } + return content; +} + +export function reconcileRichEditorAutomergeDocument( + handle: DocHandle, + document: ProseMirrorNode, + extensions: Extensions, +): void { + let hasStructuralLeaf = false; + document.descendants((node) => { + if (node.type.name === "horizontalRule") { + hasStructuralLeaf = true; + return false; + } + + return true; + }); + if (!hasStructuralLeaf) return; + + const adapter = createSchemaAdapter(extensions, document.type.schema); + const spans = pmNodeToSpans(adapter, document); + handle.change((draft) => { + Automerge.updateSpans( + draft, + textPath, + spans, + adapter.updateSpansConfig(), + ); + }); +} + +export function createRichEditorCollaborationExtension( + handle: DocHandle, + extensions: Extensions, +): Extension { + return Extension.create({ + name: "automergeCollaboration", + priority: 1_000, + + addProseMirrorPlugins() { + const adapter = createSchemaAdapter(extensions, this.editor.schema); + return [ + explicitBlockIdentityPlugin(), + createAutomergeSyncPlugin(adapter, handle, textPath), + ]; + }, + }); +} + +export function explicitBlockIdentityPlugin(): Plugin { + return new Plugin({ + appendTransaction(transactions, _oldState, state) { + if (!transactions.some(transaction => transaction.docChanged)) { + return null; + } + + const transaction = state.tr; + let changed = false; + state.doc.descendants((node, position, parent) => { + if (node.attrs.isAmgBlock !== false) return true; + + const isTopLevelTextBlock = parent === state.doc && ( + node.type.name === "paragraph" + || node.type.name === "heading" + || node.type.name === "blockquote" + || node.type.name === "codeBlock" + ); + if (!isTopLevelTextBlock && node.type.name !== "listItem") { + return true; + } + + transaction.setNodeMarkup( + position, + undefined, + { + ...node.attrs, + isAmgBlock: true, + }, + ); + changed = true; + + return true; + }); + if (!changed) return null; + + transaction.setMeta("addToHistory", false); + + return transaction; + }, + }); +} + +export function createSchemaAdapter( + extensions: Extensions, + targetSchema?: Schema, +): SchemaAdapter { + const sourceSchema = getSchema(extensions); + const nodes: Record = {}; + const marks: Record = {}; + + sourceSchema.spec.nodes.forEach((name, spec) => { + nodes[name] = { + ...spec, + automerge: automergeNodeMapping(name), + }; + }); + sourceSchema.spec.marks.forEach((name, spec) => { + marks[name] = { + ...spec, + automerge: automergeMarkMapping(name), + }; + }); + + const adapter = new SchemaAdapter({ nodes, marks } satisfies MappedSchemaSpec); + if (!targetSchema) return adapter; + + adapter.schema = targetSchema; + adapter.unknownBlock = targetSchema.nodes.automergeUnknownBlock; + adapter.nodeMappings = adapter.nodeMappings.map(mapping => ({ + ...mapping, + outer: mapping.outer ? targetSchema.nodes[mapping.outer.name] : null, + content: targetSchema.nodes[mapping.content.name], + })); + adapter.markMappings = adapter.markMappings.map(mapping => ({ + ...mapping, + prosemirrorMark: targetSchema.marks[mapping.prosemirrorMark.name], + })); + + return adapter; +} + +function automergeNodeMapping(name: string): MappedNodeSpec["automerge"] { + switch (name) { + case "paragraph": + return { block: "paragraph" }; + case "heading": + return { + block: "heading", + attrParsers: { + fromAutomerge: block => ({ + level: readNumberAttribute(block.attrs, "level", 1), + }), + fromProsemirror: node => ({ + level: readNumberAttribute(node.attrs, "level", 1), + }), + }, + }; + case "blockquote": + return { block: "blockquote" }; + case "codeBlock": + return { + block: "code-block", + attrParsers: { + fromAutomerge: block => ({ + language: readNullableStringAttribute(block.attrs, "language"), + }), + fromProsemirror: node => ({ + language: readNullableStringAttribute(node.attrs, "language"), + }), + }, + }; + case "horizontalRule": + return { block: "horizontal-rule" }; + case "hardBreak": + return { block: "hard-break", isEmbed: true }; + case "listItem": + return { + block: { + within: { + orderedList: "ordered-list-item", + bulletList: "unordered-list-item", + }, + }, + }; + case "table": + return { block: "table" }; + case "tableRow": + return { block: "table-row" }; + case "tableCell": + case "tableHeader": + return { + block: name === "tableCell" ? "table-cell" : "table-header", + attrParsers: { + fromAutomerge: block => ({ + colspan: readNumberAttribute(block.attrs, "colspan", 1), + rowspan: readNumberAttribute(block.attrs, "rowspan", 1), + colwidth: readNumberArrayAttribute(block.attrs, "colwidth"), + }), + fromProsemirror: node => ({ + colspan: readNumberAttribute(node.attrs, "colspan", 1), + rowspan: readNumberAttribute(node.attrs, "rowspan", 1), + colwidth: readNumberArrayAttribute(node.attrs, "colwidth"), + }), + }, + }; + case "automergeUnknownBlock": + return { unknownBlock: true }; + default: + return undefined; + } +} + +function automergeMarkMapping(name: string): MappedMarkSpec["automerge"] { + switch (name) { + case "bold": + return { markName: "strong" }; + case "italic": + return { markName: "em" }; + case "strike": + case "underline": + case "code": + return { markName: name }; + case "link": + return { + markName: "link", + parsers: { + fromAutomerge: (value) => { + if (typeof value !== "string") return { href: "", title: null }; + const parsed = parseJSONObject(value); + return { + href: readStringAttribute(parsed, "href", ""), + title: readNullableStringAttribute(parsed, "title"), + }; + }, + fromProsemirror: (mark: Mark) => JSON.stringify({ + href: readStringAttribute(mark.attrs, "href", ""), + title: readNullableStringAttribute(mark.attrs, "title"), + }), + }, + }; + default: + return undefined; + } +} + +function parseJSONObject(value: string): Record { + const parsed: unknown = JSON.parse(value); + if (!isJSONObject(parsed)) { + throw new Error("expected a JSON object"); + } + return parsed; +} + +function isJSONObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function markAutomergeStructuralBlocks( + node: Record, +): void { + const type = node.type; + if ( + type === "horizontalRule" + || type === "table" + || type === "tableRow" + || type === "tableCell" + || type === "tableHeader" + ) { + const attrs = isJSONObject(node.attrs) ? node.attrs : {}; + attrs.isAmgBlock = true; + node.attrs = attrs; + } + + if (!Array.isArray(node.content)) return; + for (const child of node.content) { + if (isJSONObject(child)) markAutomergeStructuralBlocks(child); + } +} + +function readNumberAttribute( + attributes: unknown, + name: string, + fallback: number, +): number { + if (!isJSONObject(attributes)) return fallback; + const value = attributes[name]; + return typeof value === "number" ? value : fallback; +} + +function readStringAttribute( + attributes: unknown, + name: string, + fallback: string, +): string { + if (!isJSONObject(attributes)) return fallback; + const value = attributes[name]; + return typeof value === "string" ? value : fallback; +} + +function readNullableStringAttribute( + attributes: unknown, + name: string, +): string | null { + if (!isJSONObject(attributes)) return null; + const value = attributes[name]; + return typeof value === "string" ? value : null; +} + +function readNumberArrayAttribute( + attributes: unknown, + name: string, +): number[] | null { + if (!isJSONObject(attributes)) return null; + const value = attributes[name]; + if (!Array.isArray(value)) return null; + + const numbers = value.filter(item => typeof item === "number"); + return numbers.length === value.length ? numbers : null; +} diff --git a/packages/ui/src/RichEditor/collaborationDebug.ts b/packages/ui/src/RichEditor/collaborationDebug.ts new file mode 100644 index 0000000000..7a7fd10c93 --- /dev/null +++ b/packages/ui/src/RichEditor/collaborationDebug.ts @@ -0,0 +1,97 @@ +// 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 * as Automerge from "@automerge/automerge"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +const collaborationDebugStorageKey = "probo:collaboration-debug"; +const collaborationDebugPrefix = "[probo:collaboration]"; + +export function collaborationDebug( + event: string, + details: Record, +): void { + if (!collaborationDebugEnabled()) return; + console.info(collaborationDebugPrefix, event, JSON.stringify(details)); +} + +export function summarizeAutomergeSpans( + document: Automerge.Doc<{ body: string }>, +): Array> { + return Automerge.spans(document, ["body"]).map((span, index) => { + if (span.type === "text") { + return { + index, + kind: "text", + length: span.value.length, + marks: Object.keys(span.marks ?? {}).sort(), + }; + } + + return { + index, + kind: "block", + type: automergeString(span.value.type), + parents: automergeStringArray(span.value.parents), + isEmbed: span.value.isEmbed, + attrs: Object.keys( + isRecord(span.value.attrs) ? span.value.attrs : {}, + ).sort(), + }; + }); +} + +export function summarizeProseMirrorDocument( + document: ProseMirrorNode, +): Array> { + const nodes: Array> = []; + document.descendants((node, position, parent) => { + nodes.push({ + position, + type: node.type.name, + parent: parent?.type.name ?? null, + isText: node.isText, + textLength: node.isText ? node.text?.length ?? 0 : 0, + isAmgBlock: node.attrs.isAmgBlock ?? null, + }); + return true; + }); + return nodes; +} + +function collaborationDebugEnabled(): boolean { + return typeof window !== "undefined" + && window.localStorage.getItem(collaborationDebugStorageKey) === "1"; +} + +function automergeString(value: unknown): string { + if (typeof value === "string") return value; + if (Automerge.isImmutableString(value)) return value.val; + return ""; +} + +function automergeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map(automergeString); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/ui/src/RichEditor/presence.ts b/packages/ui/src/RichEditor/presence.ts new file mode 100644 index 0000000000..9e3055426f --- /dev/null +++ b/packages/ui/src/RichEditor/presence.ts @@ -0,0 +1,143 @@ +// 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 type { DocHandle } from "@automerge/prosemirror"; +import { Extension } from "@tiptap/core"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +import type { RichEditorAutomergeDocument } from "./collaboration"; + +export type RichEditorPresence = { + connectionID: string; + identityID: string; + anchorPosition: number; + headPosition: number; +}; + +export type RichEditorCollaborationHandle = DocHandle & { + updatePresence?: (anchorPosition: number, headPosition: number) => void; + onPresence?: (listener: (presences: RichEditorPresence[]) => void) => () => void; +}; + +const presencePluginKey = new PluginKey("automerge-presence"); + +export function createRichEditorPresenceExtension( + handle: RichEditorCollaborationHandle, +): Extension { + return Extension.create({ + name: "automergePresence", + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: presencePluginKey, + state: { + init: () => DecorationSet.empty, + apply(transaction, decorations) { + const presences = transaction.getMeta(presencePluginKey) as + | RichEditorPresence[] + | undefined; + if (!presences) return decorations.map(transaction.mapping, transaction.doc); + return decorationsForPresences(transaction.doc, presences); + }, + }, + props: { + decorations(state) { + return presencePluginKey.getState(state); + }, + }, + view: (view) => { + const unsubscribe = handle.onPresence?.((presences) => { + view.dispatch(view.state.tr.setMeta(presencePluginKey, presences)); + }); + return { + destroy() { + unsubscribe?.(); + }, + }; + }, + }), + ]; + }, + }); +} + +function decorationsForPresences( + pmDocument: ProseMirrorNode, + presences: RichEditorPresence[], +): DecorationSet { + const decorations: Decoration[] = []; + for (const presence of presences) { + const documentSize = pmDocument.content.size; + const anchor = clamp(presence.anchorPosition, 0, documentSize); + const head = clamp(presence.headPosition, 0, documentSize); + const from = Math.min(anchor, head); + const to = Math.max(anchor, head); + const color = presenceColor(presence.identityID); + + if (from !== to) { + decorations.push( + Decoration.inline( + from, + to, + { + style: `background-color: ${color}33`, + }, + ), + ); + } + decorations.push( + Decoration.widget( + head, + () => { + const cursor = document.createElement("span"); + cursor.setAttribute("aria-label", "Collaborator cursor"); + cursor.setAttribute("title", "Collaborator"); + cursor.style.borderLeft = `2px solid ${color}`; + cursor.style.height = "1.2em"; + cursor.style.marginLeft = "-1px"; + cursor.style.pointerEvents = "none"; + return cursor; + }, + { + key: presence.connectionID, + side: 1, + }, + ), + ); + } + + return DecorationSet.create(pmDocument, decorations); +} + +function presenceColor(identityID: string): string { + let hash = 0; + for (const character of identityID) { + hash = ((hash << 5) - hash + character.charCodeAt(0)) | 0; + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue} 70% 45%)`; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(Math.max(value, minimum), maximum); +} diff --git a/packages/ui/src/RichEditor/prosemirrorBridge.bench.ts b/packages/ui/src/RichEditor/prosemirrorBridge.bench.ts new file mode 100644 index 0000000000..228438f24a --- /dev/null +++ b/packages/ui/src/RichEditor/prosemirrorBridge.bench.ts @@ -0,0 +1,97 @@ +// 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 * as Automerge from "@automerge/automerge"; +import { pmDocFromSpans, pmNodeToSpans } from "@automerge/prosemirror"; +import { bench, describe } from "vitest"; + +import { + createRichEditorAutomergeDocument, + createSchemaAdapter, +} from "./collaboration"; +import { richEditorCollaborationExtensions } from "./RichEditor"; + +const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + +const paragraphDocumentJSON = { + type: "doc", + content: Array.from({ length: 1_000 }, () => ({ + type: "paragraph", + attrs: { isAmgBlock: true }, + content: [{ type: "text", text: "a".repeat(100) }], + })), +}; +const paragraphDocument = adapter.schema.nodeFromJSON(paragraphDocumentJSON); +const paragraphAutomerge = createRichEditorAutomergeDocument( + JSON.stringify(paragraphDocumentJSON), + richEditorCollaborationExtensions, +); +const paragraphSpans = Automerge.spans(paragraphAutomerge, ["body"]); + +const tableCell = (text: string) => ({ + type: "tableCell", + attrs: { + isAmgBlock: true, + colspan: 1, + rowspan: 1, + colwidth: null, + }, + content: [{ + type: "paragraph", + content: [{ type: "text", text }], + }], +}); + +const tableDocumentJSON = { + type: "doc", + content: [{ + type: "table", + content: Array.from({ length: 100 }, (_, row) => ({ + type: "tableRow", + content: Array.from({ length: 10 }, (_, column) => + tableCell(`${row}:${column}:${"a".repeat(20)}`), + ), + })), + }], +}; +const tableDocument = adapter.schema.nodeFromJSON(tableDocumentJSON); +const tableAutomerge = createRichEditorAutomergeDocument( + JSON.stringify(tableDocumentJSON), + richEditorCollaborationExtensions, +); +const tableSpans = Automerge.spans(tableAutomerge, ["body"]); + +describe("ProseMirror bridge", () => { + bench("pmNodeToSpans / 1,000 paragraphs / 100 KiB", () => { + pmNodeToSpans(adapter, paragraphDocument); + }); + + bench("pmDocFromSpans / 1,000 paragraphs / 100 KiB", () => { + pmDocFromSpans(adapter, paragraphSpans); + }); + + bench("pmNodeToSpans / table 100x10", () => { + pmNodeToSpans(adapter, tableDocument); + }); + + bench("pmDocFromSpans / table 100x10", () => { + pmDocFromSpans(adapter, tableSpans); + }); +}); diff --git a/packages/ui/src/RichEditor/prosemirrorPropertyCorpus.ts b/packages/ui/src/RichEditor/prosemirrorPropertyCorpus.ts new file mode 100644 index 0000000000..37eea312f3 --- /dev/null +++ b/packages/ui/src/RichEditor/prosemirrorPropertyCorpus.ts @@ -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. + +export type PropertyCorpusEntry = { name: string; doc: unknown }; + +const MARKS = ["bold", "italic", "strike", "underline"] as const; +const WORDS = [ + "alpha", + "beta", + "control", + "delta", + "evidence", + "finding", + "gamma", + "policy", + "risk", + "task", + "😀", + "e\u0301", +] as const; + +function random(seed: number): () => number { + let state = seed >>> 0; + + return () => { + state += 0x6D2B79F5; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + + return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296; + }; +} + +function integer(next: () => number, minimum: number, maximum: number): number { + return minimum + Math.floor(next() * (maximum - minimum + 1)); +} + +function choose(next: () => number, values: readonly T[]): T { + return values[integer(next, 0, values.length - 1)]; +} + +function randomText(next: () => number): string { + return Array.from( + { length: integer(next, 1, 4) }, + () => choose(next, WORDS), + ).join(" "); +} + +function randomMarks(next: () => number): unknown[] | undefined { + if (next() < 0.35) return undefined; + + if (next() < 0.15) { + return [{ type: "code" }]; + } + + const marks: unknown[] = []; + for (const mark of MARKS) { + if (next() < 0.28) marks.push({ type: mark }); + } + if (next() < 0.2) { + marks.push({ + type: "link", + attrs: { + href: `https://example.com/${integer(next, 1, 99)}`, + title: next() < 0.5 ? null : `Link ${integer(next, 1, 9)}`, + }, + }); + } + + return marks.length > 0 ? marks : undefined; +} + +function randomInlineContent(next: () => number): unknown[] { + const content: unknown[] = []; + const runs = integer(next, 1, 4); + + for (let index = 0; index < runs; index++) { + const marks = randomMarks(next); + content.push({ + type: "text", + text: randomText(next), + ...(marks ? { marks } : {}), + }); + + if (index < runs - 1 && next() < 0.15) { + content.push({ type: "hardBreak" }); + } + } + + return content; +} + +function paragraph(next: () => number): unknown { + if (next() < 0.12) return { type: "paragraph" }; + + return { type: "paragraph", content: randomInlineContent(next) }; +} + +function list( + next: () => number, + type: "bulletList" | "orderedList", + allowNested: boolean, +): unknown { + const items = Array.from({ length: integer(next, 1, 4) }, () => { + const content: unknown[] = [paragraph(next)]; + if (allowNested && next() < 0.3) { + content.push( + list( + next, + next() < 0.5 ? "bulletList" : "orderedList", + false, + ), + ); + } + + return { type: "listItem", content }; + }); + + return { type, content: items }; +} + +function table(next: () => number): unknown { + const rows = integer(next, 1, 3); + const columns = integer(next, 1, 3); + + return { + type: "table", + content: Array.from({ length: rows }, (_, row) => ({ + type: "tableRow", + content: Array.from({ length: columns }, () => ({ + type: row === 0 && next() < 0.35 ? "tableHeader" : "tableCell", + attrs: { + colspan: 1, + rowspan: 1, + colwidth: null, + }, + content: [paragraph(next)], + })), + })), + }; +} + +function block(next: () => number): unknown { + switch (integer(next, 0, 7)) { + case 0: + return paragraph(next); + case 1: + return { + type: "heading", + attrs: { level: integer(next, 1, 6) }, + content: randomInlineContent(next), + }; + case 2: + return { + type: "blockquote", + content: Array.from( + { length: integer(next, 1, 3) }, + () => paragraph(next), + ), + }; + case 3: + return { + type: "codeBlock", + attrs: { language: next() < 0.35 ? "mermaid" : null }, + content: [{ type: "text", text: randomText(next) }], + }; + case 4: + return { type: "horizontalRule" }; + case 5: + return list(next, "bulletList", true); + case 6: + return list(next, "orderedList", true); + default: + return table(next); + } +} + +function documentForSeed(seed: number): unknown { + const next = random(seed); + + return { + type: "doc", + content: Array.from( + { length: integer(next, 1, 7) }, + () => block(next), + ), + }; +} + +export function generatedPropertyCorpus(count = 256): PropertyCorpusEntry[] { + return Array.from({ length: count }, (_, index) => { + const seed = (0xA017E2D5 + Math.imul(index + 1, 0x9E3779B1)) >>> 0; + + return { + name: `property-seed-${seed.toString(16).padStart(8, "0")}`, + doc: documentForSeed(seed), + }; + }); +} diff --git a/packages/ui/src/RichEditor/prosemirrorRenderParity.test.ts b/packages/ui/src/RichEditor/prosemirrorRenderParity.test.ts new file mode 100644 index 0000000000..391eb29685 --- /dev/null +++ b/packages/ui/src/RichEditor/prosemirrorRenderParity.test.ts @@ -0,0 +1,896 @@ +// 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 { Buffer } from "node:buffer"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { gunzipSync, gzipSync } from "node:zlib"; + +import * as Automerge from "@automerge/automerge"; +import { + type DocHandle, + pmDocFromSpans, + pmNodeToSpans, +} from "@automerge/prosemirror"; +import { getSchema } from "@tiptap/core"; +import { + Fragment, + type Schema, +} from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; +import { describe, expect, it } from "vitest"; + +import { createAutomergeSyncPlugin } from "./AutomergeSyncPlugin"; +import { + createRichEditorAutomergeDocument, + createSchemaAdapter, + explicitBlockIdentityPlugin, + markAutomergeStructuralBlocks, + type RichEditorAutomergeDocument, +} from "./collaboration"; +import { generatedPropertyCorpus } from "./prosemirrorPropertyCorpus"; +import { richEditorCollaborationExtensions } from "./RichEditor"; + +// The Go renderer in pkg/automerge/prosemirror is a second implementation of the +// span -> ProseMirror-document conversion performed by @automerge/prosemirror's +// pmDocFromSpans. This file is the oracle: for a corpus of realistic documents it +// records the Automerge document bytes together with the canonical ProseMirror +// JSON the official library produces, so the Go differential test can assert byte +// parity against upstream. Run with GEN_PROSEMIRROR_PARITY=1 to (re)write the +// fixture; otherwise the test guards the committed fixture against frontend drift. + +type CorpusEntry = { name: string; doc: unknown }; + +type EditScenario = { + name: string; + build: () => Automerge.Doc; +}; + +type FixtureEntry = { + name: string; + document: string; + expected: unknown; + spans: unknown; +}; + +const fixturePath = fileURLToPath( + new URL( + "../../../../pkg/automerge/prosemirror/testdata/upstream-render.json.gz", + import.meta.url, + ), +); + +function tableCell(text: string): unknown { + return { + type: "tableCell", + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} + +function tableHeader(text: string): unknown { + return { + type: "tableHeader", + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }; +} + +const curatedCorpus: CorpusEntry[] = [ + { + name: "empty-document", + doc: { type: "doc", content: [{ type: "paragraph" }] }, + }, + { + name: "paragraph-plain", + doc: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hello world" }] }], + }, + }, + { + name: "heading-with-marks", + doc: { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Policy", marks: [{ type: "bold" }] }], + }, + ], + }, + }, + { + name: "adjacent-mark-runs", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "A", marks: [{ type: "bold" }, { type: "italic" }] }, + { type: "text", text: "B", marks: [{ type: "italic" }] }, + { type: "text", text: "C" }, + ], + }, + ], + }, + }, + { + name: "all-inline-marks", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "b", marks: [{ type: "bold" }] }, + { type: "text", text: "i", marks: [{ type: "italic" }] }, + { type: "text", text: "s", marks: [{ type: "strike" }] }, + { type: "text", text: "u", marks: [{ type: "underline" }] }, + { type: "text", text: "c", marks: [{ type: "code" }] }, + ], + }, + ], + }, + }, + { + name: "stacked-marks", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "stack", + marks: [ + { type: "underline" }, + { type: "strike" }, + { type: "italic" }, + { type: "bold" }, + { type: "code" }, + ], + }, + ], + }, + ], + }, + }, + { + name: "link-with-bold", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "site", + marks: [ + { type: "link", attrs: { href: "https://example.com", title: null } }, + { type: "bold" }, + ], + }, + ], + }, + ], + }, + }, + { + name: "link-mark", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Read " }, + { + type: "text", + text: "more", + marks: [{ type: "link", attrs: { href: "https://example.com", title: "Example" } }], + }, + ], + }, + ], + }, + }, + { + name: "blockquote", + doc: { + type: "doc", + content: [ + { type: "blockquote", content: [{ type: "paragraph", content: [{ type: "text", text: "Quoted" }] }] }, + { type: "paragraph", content: [{ type: "text", text: "After" }] }, + ], + }, + }, + { + name: "code-block-language", + doc: { + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: "mermaid" }, content: [{ type: "text", text: "graph TD; A-->B" }] }, + ], + }, + }, + { + name: "code-block-no-language", + doc: { + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "plain" }] }, + ], + }, + }, + { + name: "horizontal-rule-between-paragraphs", + doc: { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Above" }] }, + { type: "horizontalRule" }, + { type: "paragraph", content: [{ type: "text", text: "Below" }] }, + ], + }, + }, + { + name: "hard-break-inside-paragraph", + doc: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "A" }, + { type: "hardBreak" }, + { type: "text", text: "B" }, + ], + }, + ], + }, + }, + { + name: "bullet-list-consecutive-items", + doc: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "One" }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Two" }] }] }, + ], + }, + ], + }, + }, + { + name: "ordered-list", + doc: { + type: "doc", + content: [ + { + type: "orderedList", + content: [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "First" }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Second" }] }] }, + ], + }, + ], + }, + }, + { + name: "nested-bullet-list", + doc: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Outer" }] }, + { + type: "bulletList", + content: [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Inner" }] }] }, + ], + }, + ], + }, + ], + }, + ], + }, + }, + { + name: "table-header-and-cells", + doc: { + type: "doc", + content: [ + { + type: "table", + content: [ + { type: "tableRow", content: [tableHeader("H1"), tableHeader("H2")] }, + { type: "tableRow", content: [tableCell("A"), tableCell("B")] }, + ], + }, + ], + }, + }, + { + name: "mixed-document", + doc: { + type: "doc", + content: [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "Title" }] }, + { type: "paragraph", content: [{ type: "text", text: "Intro" }] }, + { + type: "bulletList", + content: [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "Item", marks: [{ type: "bold" }] }] }] }, + ], + }, + { type: "horizontalRule" }, + { type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "code()" }] }, + ], + }, + }, +]; + +const corpus: CorpusEntry[] = [ + ...curatedCorpus, + ...generatedPropertyCorpus(), +]; + +// runEditor drives an Automerge document through the real collaboration sync +// plugin, exactly as the editor does, so the resulting spans reflect arrangements +// that only arise from editing rather than from loading a clean document. +function runEditor( + initialDoc: unknown, + edit: (context: { state: EditorState; schema: Schema }) => EditorState, +): Automerge.Doc { + const document = createRichEditorAutomergeDocument( + JSON.stringify(initialDoc), + richEditorCollaborationExtensions, + ); + + return editDocument(document, edit); +} + +function editDocument( + initialDocument: Automerge.Doc, + edit: (context: { state: EditorState; schema: Schema }) => EditorState, +): Automerge.Doc { + let document = initialDocument; + const handle: DocHandle = { + doc: () => document, + change: (change) => { + document = Automerge.change(document, change); + }, + on: () => {}, + off: () => {}, + }; + const schema = getSchema(richEditorCollaborationExtensions); + const adapter = createSchemaAdapter(richEditorCollaborationExtensions, schema); + const initialAdapter = createSchemaAdapter(richEditorCollaborationExtensions); + const pmDocument = schema.nodeFromJSON( + pmDocFromSpans(initialAdapter, Automerge.spans(document, ["body"])).toJSON(), + ); + const state = EditorState.create({ + schema, + doc: pmDocument, + plugins: [ + explicitBlockIdentityPlugin(), + createAutomergeSyncPlugin(adapter, handle, ["body"]), + ], + }); + + const finalState = edit({ state, schema }); + const syncedDocument = pmDocFromSpans( + adapter, + Automerge.spans(document, ["body"]), + ); + expect(canonical(syncedDocument.toJSON() as never)).toEqual( + canonical(finalState.doc.toJSON() as never), + ); + + return document; +} + +function concurrentEdit( + initialDoc: unknown, + leftEdit: (context: { state: EditorState; schema: Schema }) => EditorState, + rightEdit: (context: { state: EditorState; schema: Schema }) => EditorState, +): Automerge.Doc { + const base = createRichEditorAutomergeDocument( + JSON.stringify(initialDoc), + richEditorCollaborationExtensions, + ); + const left = editDocument( + Automerge.clone(base, { actor: "10000000000000000000000000000000" }), + leftEdit, + ); + const right = editDocument( + Automerge.clone(base, { actor: "20000000000000000000000000000000" }), + rightEdit, + ); + + return Automerge.merge(left, right); +} + +function findTextPosition(state: EditorState, text: string): number { + let position: number | undefined; + state.doc.descendants((node, at) => { + if (node.isText && node.text === text) { + position = at; + return false; + } + + return true; + }); + if (position === undefined) throw new Error(`missing text ${text}`); + + return position; +} + +const editScenarios: EditScenario[] = [ + { + name: "edit-divider-inserted-then-typed", + build: () => + runEditor( + { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Before" }] }], + }, + ({ state, schema }) => { + const divider = schema.nodes.horizontalRule.create(); + const paragraph = schema.nodes.paragraph.create(); + let next = state.applyTransaction( + state.tr.insert( + state.doc.content.size, + Fragment.fromArray([divider, paragraph]), + ), + ).state; + const lastParagraphPosition = next.doc.content.size - paragraph.nodeSize; + next = next.applyTransaction( + next.tr.insertText("After", lastParagraphPosition + 1), + ).state; + + return next; + }, + ), + }, + { + name: "edit-two-dividers-then-typed", + build: () => + runEditor( + { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Top" }] }], + }, + ({ state, schema }) => { + const divider = schema.nodes.horizontalRule.create(); + const paragraph = schema.nodes.paragraph.create(); + let next = state.applyTransaction( + state.tr.insert( + state.doc.content.size, + Fragment.fromArray([divider, paragraph, divider.copy(), paragraph.copy()]), + ), + ).state; + const lastParagraphPosition = next.doc.content.size - paragraph.nodeSize; + next = next.applyTransaction( + next.tr.insertText("Bottom", lastParagraphPosition + 1), + ).state; + + return next; + }, + ), + }, + { + name: "edit-table-cell-typed", + build: () => + runEditor(tableDocument(), ({ state }) => + state.applyTransaction( + state.tr.insertText("X", findTextPosition(state, "A") + 1), + ).state, + ), + }, + { + name: "edit-bold-toggle-with-unmarked-middle", + build: () => + runEditor( + { + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "text", text: "Alpha Beta" }], + }], + }, + ({ state, schema }) => { + const position = findTextPosition(state, "Alpha Beta"); + const bold = schema.marks.bold.create(); + let next = state.applyTransaction( + state.tr.addMark(position, position + 10, bold), + ).state; + next = next.applyTransaction( + next.tr.removeMark(position + 2, position + 7, schema.marks.bold), + ).state; + next = next.applyTransaction( + next.tr.insertText("X", position + 7), + ).state; + + return next; + }, + ), + }, + { + name: "edit-overlapping-link-and-italic", + build: () => + runEditor( + { + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "text", text: "Link target" }], + }], + }, + ({ state, schema }) => { + const position = findTextPosition(state, "Link target"); + const link = schema.marks.link.create({ + href: "https://example.com/target", + title: "Target", + }); + const italic = schema.marks.italic.create(); + let next = state.applyTransaction( + state.tr.addMark(position, position + 11, link), + ).state; + next = next.applyTransaction( + next.tr.addMark(position + 5, position + 11, italic), + ).state; + next = next.applyTransaction( + next.tr.removeMark(position + 8, position + 11, schema.marks.link), + ).state; + + return next; + }, + ), + }, + { + name: "edit-mark-over-emoji", + build: () => + runEditor( + { + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "text", text: "A😀BC" }], + }], + }, + ({ state, schema }) => { + const position = findTextPosition(state, "A😀BC"); + const underline = schema.marks.underline.create(); + let next = state.applyTransaction( + state.tr.addMark(position + 1, position + 3, underline), + ).state; + next = next.applyTransaction( + next.tr.insertText("🙂", position + 3), + ).state; + + return next; + }, + ), + }, + { + name: "concurrent-overlapping-marks", + build: () => + concurrentEdit( + { + type: "doc", + content: [{ + type: "paragraph", + content: [{ type: "text", text: "Concurrent" }], + }], + }, + ({ state, schema }) => { + const position = findTextPosition(state, "Concurrent"); + + return state.applyTransaction( + state.tr.addMark( + position, + position + 6, + schema.marks.bold.create(), + ), + ).state; + }, + ({ state, schema }) => { + const position = findTextPosition(state, "Concurrent"); + + return state.applyTransaction( + state.tr.addMark( + position + 3, + position + 10, + schema.marks.italic.create(), + ), + ).state; + }, + ), + }, + { + name: "concurrent-divider-and-marked-text", + build: () => + concurrentEdit( + { + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Above" }] }, + { type: "paragraph", content: [{ type: "text", text: "Below" }] }, + ], + }, + ({ state, schema }) => { + const divider = schema.nodes.horizontalRule.create(); + + return state.applyTransaction( + state.tr.insert(state.doc.child(0).nodeSize, divider), + ).state; + }, + ({ state, schema }) => { + const position = findTextPosition(state, "Below"); + + return state.applyTransaction( + state.tr.addMark( + position, + position + 5, + schema.marks.underline.create(), + ), + ).state; + }, + ), + }, + { + name: "concurrent-table-row-insertions", + build: () => + concurrentEdit( + tableDocument(), + ({ state, schema }) => + insertTableRow(state, schema, "Left"), + ({ state, schema }) => + insertTableRow(state, schema, "Right"), + ), + }, +]; + +function insertTableRow( + state: EditorState, + schema: Schema, + text: string, +): EditorState { + const paragraph = schema.nodes.paragraph.create( + null, + schema.text(text), + ); + const cell = schema.nodes.tableCell.create( + { + isAmgBlock: true, + colspan: 1, + rowspan: 1, + colwidth: null, + }, + paragraph, + ); + const row = schema.nodes.tableRow.create( + { isAmgBlock: true }, + [cell], + ); + const table = state.doc.firstChild; + if (!table) throw new Error("expected table"); + + return state.applyTransaction( + state.tr.insert(table.nodeSize - 1, row), + ).state; +} + +function tableDocument(): unknown { + return { + type: "doc", + content: [ + { + type: "table", + content: [ + { type: "tableRow", content: [tableCell("A"), tableCell("B")] }, + { type: "tableRow", content: [tableCell("C"), tableCell("D")] }, + ], + }, + ], + }; +} + +function canonicalAttrs( + type: string, + attrs: Record | undefined, +): Record | undefined { + const source = attrs ?? {}; + switch (type) { + case "heading": + return { level: source.level }; + case "codeBlock": + return { language: source.language ?? null }; + case "tableCell": + case "tableHeader": + return { + colspan: source.colspan, + rowspan: source.rowspan, + colwidth: source.colwidth ?? null, + }; + default: + return undefined; + } +} + +function canonicalMark(mark: { + type: string; + attrs?: Record; +}): unknown { + if (mark.type === "link") { + const attrs = mark.attrs ?? {}; + return { + type: "link", + attrs: { href: attrs.href ?? "", title: attrs.title ?? null }, + }; + } + return { type: mark.type }; +} + +// canonical projects the official ProseMirror JSON onto the shape the Go renderer +// targets: editor-only attributes (isAmgBlock, unknownAttrs, align) and display-only +// link attributes (target, rel, class) are dropped, and empty content/marks/attrs are +// omitted to mirror the Go struct's omitempty encoding. +function canonical(node: { + type: string; + attrs?: Record; + text?: string; + marks?: Array<{ type: string; attrs?: Record }>; + content?: unknown[]; +}): unknown { + const out: Record = { type: node.type }; + const attrs = canonicalAttrs(node.type, node.attrs); + if (attrs !== undefined) out.attrs = attrs; + if (typeof node.text === "string") out.text = node.text; + if (node.marks && node.marks.length > 0) out.marks = node.marks.map(canonicalMark); + if (node.content && node.content.length > 0) { + out.content = node.content.map(child => canonical(child as never)); + } + return out; +} + +function normalizeAutomerge(value: unknown): unknown { + if (Automerge.isImmutableString(value)) return value.val; + if (Array.isArray(value)) return value.map(normalizeAutomerge); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, normalizeAutomerge(item)]), + ); + } + + return value; +} + +function normalizeSpans(spans: unknown[]): unknown[] { + return spans.map((span) => { + const normalized = normalizeAutomerge(span) as Record; + const marks = normalized.marks; + if ( + marks !== null + && typeof marks === "object" + && !Array.isArray(marks) + && Object.keys(marks).length === 0 + ) { + delete normalized.marks; + } + + return normalized; + }); +} + +function entryFromDocument( + name: string, + document: Automerge.Doc, + sourceJSON?: unknown, +): FixtureEntry { + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const spans = Automerge.spans(document, ["body"]); + const renderedDocument = pmDocFromSpans(adapter, spans); + const rendered: unknown = renderedDocument.toJSON(); + const reverseSpans = pmNodeToSpans( + adapter, + sourceJSON === undefined + ? renderedDocument + : adapter.schema.nodeFromJSON(sourceJSON), + ); + + // The direct pmNodeToSpans result and the spans materialized after + // updateSpans must describe the same rich text. This is the frontend half of + // the reverse-direction oracle; the Go test loads the saved document and + // independently compares its native spans with this committed result. + expect(normalizeSpans(reverseSpans), name).toEqual( + normalizeSpans(spans), + ); + + return { + name, + document: Buffer.from(Automerge.save(document)).toString("base64"), + expected: canonical(rendered as never), + spans: normalizeSpans(reverseSpans), + }; +} + +function build(): FixtureEntry[] { + const fromDocuments = corpus.map(({ name, doc }) => { + const document = createRichEditorAutomergeDocument( + JSON.stringify(doc), + richEditorCollaborationExtensions, + ); + const sourceJSON = structuredClone(doc) as Record; + markAutomergeStructuralBlocks(sourceJSON); + + return entryFromDocument(name, document, sourceJSON); + }); + + const fromEdits = editScenarios.map(({ name, build: buildDocument }) => + entryFromDocument(name, buildDocument()), + ); + + return [...fromDocuments, ...fromEdits]; +} + +describe("ProseMirror render parity fixture", () => { + it("matches the committed Go differential fixture", () => { + const entries = build(); + + if (process.env.GEN_PROSEMIRROR_PARITY) { + writeFileSync(fixturePath, gzipSync(JSON.stringify(entries))); + return; + } + + expect(existsSync(fixturePath)).toBe(true); + const committed = JSON.parse( + gunzipSync(readFileSync(fixturePath)).toString("utf8"), + ) as FixtureEntry[]; + + expect(entries.map(entry => entry.name)).toEqual( + committed.map(entry => entry.name), + ); + for (const entry of entries) { + const match = committed.find(candidate => candidate.name === entry.name); + expect(match, entry.name).toBeDefined(); + expect(entry.expected, entry.name).toEqual(match!.expected); + expect(entry.spans, entry.name).toEqual(match!.spans); + } + }, 30_000); +}); diff --git a/packages/ui/src/RichEditor/repoDocumentId.test.ts b/packages/ui/src/RichEditor/repoDocumentId.test.ts new file mode 100644 index 0000000000..88e8fad5aa --- /dev/null +++ b/packages/ui/src/RichEditor/repoDocumentId.test.ts @@ -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. + +import { describe, expect, it } from "vitest"; + +import { + automergeUrl, + decodeDocumentId, + deriveAutomergeUrl, + deriveDocumentId, + encodeDocumentId, + parseAutomergeUrl, + validDocumentId, +} from "./repoDocumentId"; + +// A genuine @automerge/automerge-repo document id (the one the Go interop client +// uses). Decoding it validates our base58check against real upstream output. +const REAL_REPO_ID = "34YWzjYt5gPJpq5RfXAkPfPcUj1r"; + +// Canonical outputs of the Go DeriveDocumentID (pkg/automerge/collaboration). +// Equality here proves the browser and the Go agent derive the same id for a +// version, which is what makes their sync and presence line up. +const GO_DERIVED: Record = { + document_version_2Abc123: "2m7mUm61HVK58xqK8aYTGYLQ4atr", + "gid:probo:document_version:01J000000000000000000000": + "3r6ksouJrcqDzaHWPGCEzddsAEhW", + "hello world": "3ajNHtb2g2k3cYHyXLUJFwQuyr42", + "": "4AyyyhobrQ6KECw6yZaZ2Ss2eVuL", +}; + +describe("repo document id", () => { + it("decodes a real automerge-repo id and round-trips it", async () => { + const id = await decodeDocumentId(REAL_REPO_ID); + expect(id).toHaveLength(16); + expect(await encodeDocumentId(id)).toBe(REAL_REPO_ID); + expect(await validDocumentId(REAL_REPO_ID)).toBe(true); + }); + + it("derives ids that match the Go implementation exactly", async () => { + for (const [seed, expected] of Object.entries(GO_DERIVED)) { + expect(await deriveDocumentId(seed)).toBe(expected); + } + }); + + it("derives deterministically and per-seed", async () => { + const seed = "document_version_9"; + expect(await deriveDocumentId(seed)).toBe(await deriveDocumentId(seed)); + expect(await deriveDocumentId(seed)).not.toBe( + await deriveDocumentId(seed + "x"), + ); + }); + + it("round-trips arbitrary 16-byte ids, including leading zeros", async () => { + const cases: Uint8Array[] = [ + new Uint8Array(16), + new Uint8Array([0, 0, 0, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 0, 0, 0]), + new Uint8Array(16).fill(255), + new Uint8Array([ + 0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, + ]), + ]; + + for (const id of cases) { + const encoded = await encodeDocumentId(id); + expect(await validDocumentId(encoded)).toBe(true); + expect(await decodeDocumentId(encoded)).toEqual(id); + } + }); + + it("rejects a corrupted id", async () => { + const flipped = REAL_REPO_ID.endsWith("r") + ? REAL_REPO_ID.slice(0, -1) + "s" + : REAL_REPO_ID.slice(0, -1) + "r"; + await expect(decodeDocumentId(flipped)).rejects.toThrow(); + + await expect(decodeDocumentId("0OIl")).rejects.toThrow(); + expect(await validDocumentId("")).toBe(false); + }); + + it("wraps and parses the automerge: scheme", async () => { + const url = await deriveAutomergeUrl("document_version_9"); + const documentId = await parseAutomergeUrl(url); + expect(automergeUrl(documentId)).toBe(url); + expect(await validDocumentId(documentId)).toBe(true); + + await expect(parseAutomergeUrl(REAL_REPO_ID)).rejects.toThrow(); + await expect( + parseAutomergeUrl("automerge:not-a-valid-id!!"), + ).rejects.toThrow(); + }); +}); diff --git a/packages/ui/src/RichEditor/repoDocumentId.ts b/packages/ui/src/RichEditor/repoDocumentId.ts new file mode 100644 index 0000000000..1d7a64cbb8 --- /dev/null +++ b/packages/ui/src/RichEditor/repoDocumentId.ts @@ -0,0 +1,213 @@ +// 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. + +// automerge-repo document ids, mirrored byte-for-byte from the Go implementation +// in pkg/automerge/collaboration/documentid.go so a browser tab and a Go agent +// compute the same id for a document version without coordinating. This is what +// lets them share sync and, crucially, ephemeral gossip (presence and cursors): +// a peer drops an ephemeral frame whose document id it does not recognise. +// +// The format is base58check (base58 of the payload followed by the first four +// bytes of its double SHA-256), the same encoding @automerge/automerge-repo uses +// for a 16-byte document id. + +/** The automerge: URL scheme automerge-repo puts in front of a document id. */ +export const AUTOMERGE_URL_PREFIX = "automerge:"; + +/** The length of the binary document id automerge-repo base58check-encodes. */ +export const DOCUMENT_ID_BYTE_LENGTH = 16; + +// The Bitcoin base58 alphabet automerge-repo's bs58check uses. +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BASE58_RADIX = 58n; + +/** Encodes a 16-byte identifier as an automerge-repo document id. */ +export async function encodeDocumentId(id: Uint8Array): Promise { + if (id.length !== DOCUMENT_ID_BYTE_LENGTH) { + throw new Error( + `automerge document id must be ${DOCUMENT_ID_BYTE_LENGTH} bytes, got ${id.length}`, + ); + } + + return base58CheckEncode(id); +} + +/** Decodes an automerge-repo document id, rejecting a bad checksum or length. */ +export async function decodeDocumentId( + documentId: string, +): Promise { + const payload = await base58CheckDecode(documentId); + if (payload.length !== DOCUMENT_ID_BYTE_LENGTH) { + throw new Error( + `automerge document id ${JSON.stringify(documentId)} decodes to ${payload.length} bytes, want ${DOCUMENT_ID_BYTE_LENGTH}`, + ); + } + + return payload; +} + +/** Reports whether documentId is a well-formed automerge-repo document id. */ +export async function validDocumentId(documentId: string): Promise { + try { + await decodeDocumentId(documentId); + return true; + } catch { + return false; + } +} + +/** + * Derives a stable automerge-repo document id from a seed string, such as a + * Probo document-version GID, by hashing the seed and taking the first 16 bytes. + * Every peer that knows the seed computes the same id. + */ +export async function deriveDocumentId(seed: string): Promise { + const digest = await sha256(new TextEncoder().encode(seed)); + + return base58CheckEncode(digest.slice(0, DOCUMENT_ID_BYTE_LENGTH)); +} + +/** Wraps a document id in the automerge: scheme. */ +export function automergeUrl(documentId: string): string { + return AUTOMERGE_URL_PREFIX + documentId; +} + +/** Derives a stable automerge: URL from a seed string. */ +export async function deriveAutomergeUrl(seed: string): Promise { + return automergeUrl(await deriveDocumentId(seed)); +} + +/** Extracts and validates the document id from an automerge: URL. */ +export async function parseAutomergeUrl(url: string): Promise { + if (!url.startsWith(AUTOMERGE_URL_PREFIX)) { + throw new Error( + `automerge url ${JSON.stringify(url)} is missing the ${JSON.stringify(AUTOMERGE_URL_PREFIX)} scheme`, + ); + } + + const documentId = url.slice(AUTOMERGE_URL_PREFIX.length); + await decodeDocumentId(documentId); + + return documentId; +} + +async function base58CheckEncode(payload: Uint8Array): Promise { + const check = await checksum(payload); + const combined = new Uint8Array(payload.length + check.length); + combined.set(payload); + combined.set(check, payload.length); + + return base58Encode(combined); +} + +async function base58CheckDecode(encoded: string): Promise { + const decoded = base58Decode(encoded); + if (decoded.length < 4) { + throw new Error("base58check value is too short to contain a checksum"); + } + + const payload = decoded.slice(0, decoded.length - 4); + const want = decoded.slice(decoded.length - 4); + const got = await checksum(payload); + + for (let i = 0; i < 4; i++) { + if (got[i] !== want[i]) { + throw new Error("base58check checksum mismatch"); + } + } + + return payload; +} + +async function checksum(payload: Uint8Array): Promise { + const first = await sha256(payload); + const second = await sha256(first); + + return second.slice(0, 4); +} + +async function sha256(data: Uint8Array): Promise { + // Copy into a fresh ArrayBuffer-backed view so the argument is a plain + // BufferSource regardless of the input's backing store (SharedArrayBuffer). + const bytes = new Uint8Array(data.length); + bytes.set(data); + const digest = await crypto.subtle.digest("SHA-256", bytes.buffer); + + return new Uint8Array(digest); +} + +function base58Encode(input: Uint8Array): string { + let value = 0n; + for (const byte of input) { + value = value * 256n + BigInt(byte); + } + + let encoded = ""; + while (value > 0n) { + const remainder = value % BASE58_RADIX; + value = value / BASE58_RADIX; + encoded = BASE58_ALPHABET[Number(remainder)] + encoded; + } + + // Each leading zero byte is encoded as the alphabet's first character. + for (const byte of input) { + if (byte !== 0) { + break; + } + + encoded = BASE58_ALPHABET[0] + encoded; + } + + return encoded; +} + +function base58Decode(encoded: string): Uint8Array { + let value = 0n; + for (const character of encoded) { + const index = BASE58_ALPHABET.indexOf(character); + if (index < 0) { + throw new Error(`invalid base58 character ${JSON.stringify(character)}`); + } + + value = value * BASE58_RADIX + BigInt(index); + } + + const digits: number[] = []; + while (value > 0n) { + digits.unshift(Number(value % 256n)); + value = value / 256n; + } + + // Restore the leading zero bytes the encoder wrote as leading '1's. + let zeros = 0; + for (const character of encoded) { + if (character !== BASE58_ALPHABET[0]) { + break; + } + + zeros++; + } + + const result = new Uint8Array(zeros + digits.length); + result.set(digits, zeros); + + return result; +} diff --git a/packages/ui/src/RichEditor/repoPresence.test.ts b/packages/ui/src/RichEditor/repoPresence.test.ts new file mode 100644 index 0000000000..f6b5fcf399 --- /dev/null +++ b/packages/ui/src/RichEditor/repoPresence.test.ts @@ -0,0 +1,86 @@ +// 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 * as Automerge from "@automerge/automerge"; +import { describe, expect, it } from "vitest"; + +import { + createSchemaAdapter, + type RichEditorAutomergeDocument, +} from "./collaboration"; +import { richEditorCollaborationExtensions } from "./RichEditor"; +import { + pmSelectionFromPresence, + presenceFromPmSelection, +} from "./repoPresence"; + +function seededDocument( + text: string, +): Automerge.Doc { + const document = Automerge.from({ body: "" }); + return Automerge.change(document, (draft) => { + Automerge.splice(draft, ["body"], 0, 0, text); + }); +} + +describe("repo presence mapping", () => { + it("round-trips a caret between ProseMirror and Automerge cursors", () => { + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const document = seededDocument("hello world"); + + // In a single paragraph, ProseMirror position 1 is the start of the text, so + // the "w" of "world" (text offset 6) is at position 7. + const selection = presenceFromPmSelection(adapter, document, 7, 7); + expect(selection).not.toBeNull(); + + const resolved = pmSelectionFromPresence(adapter, document, selection!); + expect(resolved).toEqual({ anchorPosition: 7, headPosition: 7 }); + }); + + it("keeps a remote caret anchored across a concurrent insertion", () => { + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + let document = seededDocument("hello world"); + + const selection = presenceFromPmSelection(adapter, document, 7, 7); + expect(selection).not.toBeNull(); + + // Someone types three characters at the start of the text. + document = Automerge.change(document, (draft) => { + Automerge.splice(draft, ["body"], 0, 0, "XX "); + }); + + // The same stable selection now maps three positions later; a stored + // ProseMirror position of 7 would point at the wrong character. + const resolved = pmSelectionFromPresence(adapter, document, selection!); + expect(resolved).toEqual({ anchorPosition: 10, headPosition: 10 }); + }); + + it("carries a selection range with distinct endpoints", () => { + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + const document = seededDocument("hello world"); + + // "hello" spans text offsets 0..5, i.e. ProseMirror positions 1..6. + const selection = presenceFromPmSelection(adapter, document, 1, 6); + expect(selection).not.toBeNull(); + + const resolved = pmSelectionFromPresence(adapter, document, selection!); + expect(resolved).toEqual({ anchorPosition: 1, headPosition: 6 }); + }); +}); diff --git a/packages/ui/src/RichEditor/repoPresence.ts b/packages/ui/src/RichEditor/repoPresence.ts new file mode 100644 index 0000000000..49f2bdb45d --- /dev/null +++ b/packages/ui/src/RichEditor/repoPresence.ts @@ -0,0 +1,115 @@ +// 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 * as Automerge from "@automerge/automerge"; +import type { SchemaAdapter } from "@automerge/prosemirror"; +// These position-mapping helpers are not re-exported from the package index, so +// they are imported from the pinned build. They convert between ProseMirror +// document positions and Automerge text offsets, which is what lets a caret +// captured in the editor be stored as a stable Automerge cursor and drawn back +// at the right place after concurrent edits. +import { + amSpliceIdxToPmIdx, + pmRangeToAmRange, +} from "@automerge/prosemirror/dist/traversal.js"; + +import { + createSchemaAdapter, + type RichEditorAutomergeDocument, +} from "./collaboration"; +import { richEditorCollaborationExtensions } from "./RichEditor"; +import { + resolveSelection, + type TextSelection, + textSelection, +} from "./repoSelection"; + +// richEditorPresenceAdapter builds the schema adapter used to map positions +// between ProseMirror and Automerge for presence. It matches the schema the +// collaborative editor uses, so positions line up. +export function richEditorPresenceAdapter(): SchemaAdapter { + return createSchemaAdapter(richEditorCollaborationExtensions); +} + +// The map key of the rich-text field and the path used to read its spans. +const textField = "body"; +const textPath: Automerge.Prop[] = [textField]; + +// A collaborator's caret/selection in ProseMirror position space, ready for the +// presence decorations. +export type PmPresenceSelection = { + anchorPosition: number; + headPosition: number; +}; + +// presenceFromPmSelection converts a ProseMirror caret/selection into a stable +// Automerge-cursor selection to publish over presence. It returns null when a +// position cannot be mapped (for example a selection on a structural node), so +// the caller can skip publishing rather than send a bad selection. +export function presenceFromPmSelection( + adapter: SchemaAdapter, + document: Automerge.Doc, + anchorPosition: number, + headPosition: number, +): TextSelection | null { + const spans = Automerge.spans(document, textPath); + + const anchorOffset = pmPositionToAmOffset(adapter, spans, anchorPosition); + const headOffset = pmPositionToAmOffset(adapter, spans, headPosition); + if (anchorOffset === null || headOffset === null) { + return null; + } + + return textSelection(document, textField, anchorOffset, headOffset); +} + +// pmSelectionFromPresence resolves a presence selection's stable cursors against +// the current document and maps them back to ProseMirror positions. It returns +// null when a cursor no longer resolves (for example the surrounding text was +// deleted). +export function pmSelectionFromPresence( + adapter: SchemaAdapter, + document: Automerge.Doc, + selection: TextSelection, +): PmPresenceSelection | null { + const resolved = resolveSelection(document, selection); + const spans = Automerge.spans(document, textPath); + + const anchorPosition = amSpliceIdxToPmIdx(adapter, spans, resolved.anchor); + const headPosition = amSpliceIdxToPmIdx(adapter, spans, resolved.head); + if (anchorPosition === null || headPosition === null) { + return null; + } + + return { anchorPosition, headPosition }; +} + +function pmPositionToAmOffset( + adapter: SchemaAdapter, + spans: Automerge.Span[], + position: number, +): number | null { + const range = pmRangeToAmRange(adapter, spans, { + from: position, + to: position, + }); + + return range ? range.start : null; +} diff --git a/packages/ui/src/RichEditor/repoSelection.test.ts b/packages/ui/src/RichEditor/repoSelection.test.ts new file mode 100644 index 0000000000..de0c9e2b62 --- /dev/null +++ b/packages/ui/src/RichEditor/repoSelection.test.ts @@ -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. + +import * as Automerge from "@automerge/automerge"; +import { describe, expect, it } from "vitest"; + +import { isCollapsed, resolveSelection, textSelection } from "./repoSelection"; + +type Doc = { body: string }; + +function seededDocument(text: string): Automerge.Doc { + const document = Automerge.from({ body: "" }); + return Automerge.change(document, (draft) => { + Automerge.splice(draft, ["body"], 0, 0, text); + }); +} + +describe("repo selection", () => { + it("resolves a caret to its offset", () => { + const document = seededDocument("hello world"); + const selection = textSelection(document, "body", 6, 6); + + expect(isCollapsed(selection)).toBe(true); + expect(resolveSelection(document, selection)).toEqual({ + anchor: 6, + head: 6, + }); + }); + + it("keeps a caret anchored across a concurrent insertion", () => { + let document = seededDocument("hello world"); + // Caret on the "w" of "world". + const selection = textSelection(document, "body", 6, 6); + const before = resolveSelection(document, selection); + expect(before).toEqual({ anchor: 6, head: 6 }); + + // Someone types three characters at the start of the document. + document = Automerge.change(document, (draft) => { + Automerge.splice(draft, ["body"], 0, 0, "XX "); + }); + + // The same cursors now resolve three positions later: an integer offset of + // 6 would point at the wrong character. + const after = resolveSelection(document, selection); + expect(after).toEqual({ anchor: before.anchor + 3, head: before.head + 3 }); + }); + + it("carries a non-collapsed selection range", () => { + const document = seededDocument("hello world"); + const selection = textSelection(document, "body", 0, 5); + + expect(isCollapsed(selection)).toBe(false); + expect(resolveSelection(document, selection)).toEqual({ + anchor: 0, + head: 5, + }); + }); +}); diff --git a/packages/ui/src/RichEditor/repoSelection.ts b/packages/ui/src/RichEditor/repoSelection.ts new file mode 100644 index 0000000000..3ee9889cb3 --- /dev/null +++ b/packages/ui/src/RichEditor/repoSelection.ts @@ -0,0 +1,94 @@ +// 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 * as Automerge from "@automerge/automerge"; + +// A collaborator's caret or selection carried in automerge-repo presence, +// expressed with stable Automerge text cursors rather than integer offsets. An +// offset is invalidated by any concurrent edit before it, so a remote caret +// drawn from an offset drifts onto the wrong character; a cursor resolves to the +// position of the same character after arbitrary concurrent edits, which is what +// keeps remote carets anchored while other people type. +// +// The shape (field, anchor, head) mirrors the Go TextSelectionValue in +// pkg/automerge/collaboration so the two describe the same thing. The cursor +// values here are the JavaScript Automerge cursor type (a string), which is the +// representation browser peers exchange; cross-runtime cursor interop with Go +// agents is a separate concern because the two runtimes encode cursors +// differently. +export type TextSelection = { + // The Automerge map key of the text object the selection addresses. + field: string; + // The stable cursor for the fixed end of the selection. + anchor: Automerge.Cursor; + // The stable cursor for the moving end (the caret). When it equals the anchor + // the selection is a collapsed caret. + head: Automerge.Cursor; +}; + +// A selection resolved back to UTF-16 offsets in the current document, ready for +// ProseMirror decorations. +export type ResolvedSelection = { + anchor: number; + head: number; +}; + +// textSelection builds a stable selection from the current caret offsets in a +// text field. move controls which side of a character a collapsed caret anchors +// to; "before" keeps it in front of the following character, matching a caret +// that stays put as text is inserted after it. +export function textSelection( + document: Automerge.Doc, + field: string, + anchorOffset: number, + headOffset: number, + move: Automerge.MoveCursor = "before", +): TextSelection { + return { + field, + anchor: Automerge.getCursor(document, [field], anchorOffset, move), + head: Automerge.getCursor(document, [field], headOffset, move), + }; +} + +// resolveSelection resolves a selection's cursors to offsets in the given +// document, which may have advanced since the selection was created. +export function resolveSelection( + document: Automerge.Doc, + selection: TextSelection, +): ResolvedSelection { + return { + anchor: Automerge.getCursorPosition( + document, + [selection.field], + selection.anchor, + ), + head: Automerge.getCursorPosition( + document, + [selection.field], + selection.head, + ), + }; +} + +// isCollapsed reports whether a selection is a single caret. +export function isCollapsed(selection: TextSelection): boolean { + return selection.anchor === selection.head; +} diff --git a/packages/ui/src/RichEditor/schemaMappingParity.test.ts b/packages/ui/src/RichEditor/schemaMappingParity.test.ts new file mode 100644 index 0000000000..60848ab583 --- /dev/null +++ b/packages/ui/src/RichEditor/schemaMappingParity.test.ts @@ -0,0 +1,112 @@ +// 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 { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { createSchemaAdapter } from "./collaboration"; +import { richEditorCollaborationExtensions } from "./RichEditor"; + +// This test holds the frontend schema adapter to the shared ProseMirror <-> +// Automerge ledger. Its Go counterpart (TestSchemaMappingLedger) holds the Go +// renderer to the same file, so the two implementations of the mapping cannot +// drift apart — adding or renaming a block or mark on one side without the other +// fails a test. + +type Ledger = { + blocks: Array<{ + automerge: string; + prosemirror: string; + outer?: string; + isEmbed?: boolean; + }>; + marks: Array<{ automerge: string; prosemirror: string }>; +}; + +const ledgerPath = fileURLToPath( + new URL( + "../../../../pkg/automerge/prosemirror/testdata/schema-mapping.json", + import.meta.url, + ), +); + +function loadLedger(): Ledger { + return JSON.parse(readFileSync(ledgerPath, "utf8")) as Ledger; +} + +describe("ProseMirror schema mapping parity", () => { + it("keeps the frontend adapter aligned with the shared ledger", () => { + const ledger = loadLedger(); + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + + const adapterBlocks = adapter.nodeMappings + .map(mapping => ({ + automerge: mapping.blockName, + prosemirror: mapping.content.name, + outer: mapping.outer?.name ?? undefined, + isEmbed: mapping.isEmbed ?? false, + })) + .sort((a, b) => a.automerge.localeCompare(b.automerge)); + + const ledgerBlocks = ledger.blocks + .map(block => ({ + automerge: block.automerge, + prosemirror: block.prosemirror, + outer: block.outer ?? undefined, + isEmbed: block.isEmbed ?? false, + })) + .sort((a, b) => a.automerge.localeCompare(b.automerge)); + + expect(adapterBlocks).toEqual(ledgerBlocks); + }); + + it("keeps mark names aligned with the shared ledger", () => { + const ledger = loadLedger(); + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + + const adapterMarks = adapter.markMappings + .map(mapping => ({ + automerge: mapping.automergeMarkName, + prosemirror: mapping.prosemirrorMark.name, + })) + .sort((a, b) => a.automerge.localeCompare(b.automerge)); + + const ledgerMarks = [...ledger.marks].sort((a, b) => + a.automerge.localeCompare(b.automerge), + ); + + expect(adapterMarks).toEqual(ledgerMarks); + }); + + it("keeps mark render order aligned with the ProseMirror schema rank", () => { + const ledger = loadLedger(); + const adapter = createSchemaAdapter(richEditorCollaborationExtensions); + + const knownMarks = new Set(ledger.marks.map(mark => mark.prosemirror)); + const schemaRank: string[] = []; + adapter.schema.spec.marks.forEach((name: string) => { + if (knownMarks.has(name)) schemaRank.push(name); + }); + + expect(schemaRank).toEqual(ledger.marks.map(mark => mark.prosemirror)); + }); +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2f067a528e..6e967fea4d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -52,7 +52,12 @@ export { Field } from "./Molecules/Field/Field"; export { Input } from "./Atoms/Input/Input"; export { DurationInput } from "./Atoms/Input/DurationInput"; export { Textarea } from "./Atoms/Textarea/Textarea"; -export { Option, Select, SelectGroup, SelectLabel } from "./Atoms/Select/Select"; +export { + Option, + Select, + SelectGroup, + SelectLabel, +} from "./Atoms/Select/Select"; export { Label } from "./Atoms/Label/Label"; export { PropertyRow } from "./Atoms/PropertyRow/PropertyRow"; export { Table, Tbody, Td, Th, Thead, Tr, TrButton } from "./Atoms/Table/Table"; @@ -119,4 +124,43 @@ export { EditableRow } from "./Molecules/Table/EditableRow"; export { Toasts, useToast } from "./Atoms/Toasts/Toasts"; // Rich editor -export { RichEditor } from "./RichEditor/RichEditor"; +export { + createRichEditorAutomergeDocument, + RichEditor, + supportsRichEditorCollaboration, +} from "./RichEditor/RichEditor"; +export type { RichEditorAutomergeDocument } from "./RichEditor/collaboration"; +export type { + RichEditorCollaborationHandle, + RichEditorPresence, +} from "./RichEditor/presence"; +export { + collaborationDebug, + summarizeAutomergeSpans, +} from "./RichEditor/collaborationDebug"; +export { + AUTOMERGE_URL_PREFIX, + DOCUMENT_ID_BYTE_LENGTH, + automergeUrl, + decodeDocumentId, + deriveAutomergeUrl, + deriveDocumentId, + encodeDocumentId, + parseAutomergeUrl, + validDocumentId, +} from "./RichEditor/repoDocumentId"; +export { + isCollapsed, + resolveSelection, + textSelection, +} from "./RichEditor/repoSelection"; +export type { + ResolvedSelection, + TextSelection, +} from "./RichEditor/repoSelection"; +export { + pmSelectionFromPresence, + presenceFromPmSelection, + richEditorPresenceAdapter, +} from "./RichEditor/repoPresence"; +export type { PmPresenceSelection } from "./RichEditor/repoPresence"; diff --git a/packages/ui/src/rich-editor.css b/packages/ui/src/rich-editor.css index 2631eafb57..0d445e1d4f 100644 --- a/packages/ui/src/rich-editor.css +++ b/packages/ui/src/rich-editor.css @@ -49,6 +49,10 @@ @apply text-txt-tertiary pointer-events-none float-left h-0; } + p.is-empty-focused.slash-command-active::before { + content: none; + } + .slash-search { @apply bg-subtle rounded px-1.5 py-0.5; } diff --git a/pkg/automerge/PARITY_PLAN.md b/pkg/automerge/PARITY_PLAN.md new file mode 100644 index 0000000000..f4394d6f59 --- /dev/null +++ b/pkg/automerge/PARITY_PLAN.md @@ -0,0 +1,463 @@ +# 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 `Save(ctx, NoCompress())` 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 covered: 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). + +Snapshot writing is implemented and is now what `Save` produces, matching the +Rust and JavaScript `save()` semantics. It compacts the whole history into one +document chunk, followed by any retained orphan changes as trailing change +chunks, and DEFLATEs individual columns above a size threshold. It is gated on +byte identity with the reference for the same history across linear text, map +puts and deletes, counters, marks and unmarks, and text deletion; re-encoding a +reference-written snapshot reproduces that file exactly for the official fixture +and for reference histories covering nested objects, lists and a merged +multi-actor graph. Compaction matters for size as well as parity: a 200-commit +typing history is 21816 bytes as the old change stream and 400 bytes compacted. + +`Save` falls back to the faithful change stream (the loaded base plus each change +chunk since) when a history cannot be compacted: while isolated, or when the +change graph is not internally consistent. That stream is also what preserves +loaded bytes verbatim, so a document is never rewritten in a lossy way when it +cannot be safely compacted. `SaveIncremental` is unaffected: `Save` leaves the +incremental cursor at the end exactly as the stream save did. + +Compressed columns are not byte-identical to the reference because the DEFLATE +implementations differ, so the byte-identity gate holds only for histories small +enough that no column crosses the threshold; above it both files are valid and +load equally, and compression is a size optimization the decoder reverses on the +way back in. + +Two smaller differences remain. The reference `am_save_no_orphans` shim sets +`deflate: false`, while Rust's own `SaveOptions::default()` compresses, so +`Save(ctx, DiscardOrphans())` diverges from the shim rather than from Rust; +correcting it means rebuilding the WASM oracle. Unknown columns survive a normal +load +because the loaded bytes are kept verbatim, and `EncodeDocument` writes them back +to the table they came from when a document is re-encoded unmodified, but they +cannot be carried across a compaction of a mutated history because their rows no +longer line up with the recomputed columns; this matches Rust, which also drops +them across re-serialization. + +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. + +## Native defects found by parity reproduction (resolved) + +**Mark boundaries: matches the reference, including the error paths.** +The originally reported defect is fixed. Mark begin and end operations now hold +positions in the sequence order, insertions (including the mark boundaries +themselves) resolve their anchors through a port of the reference's insert +query, and spans are produced by a mark state machine walking that order. Text +inserted at an expanding boundary now keeps its mark after the originally marked +content is deleted, and text inserted after the whole marked range was deleted +still does not gain the mark. + +Two behaviours were essential to get right and are easy to regress: + +- a splice resolves its insertion anchor and inserts *before* deleting, matching + the reference, so replacement text is positioned against the pre-deletion + sequence and lands inside an expanding mark; +- mark precedence follows creation order, so a later unmark overrides an earlier + mark where they overlap, and a mark left open covers nothing (a zero-length + mark presents this way because its begin and end share an anchor and sibling + insertions are ordered by descending operation ID, so the end is visited + first). + +Randomized differential testing that compares mark *values* against the +reference (a stronger assertion than upstream `marks_are_okay`, which only +checks span consolidation and text) drove a series of fixes this pass: + +- an over-long splice deletion is clamped to the remaining elements rather than + rejected, matching the reference; +- a mark whose end boundary is past the end of the text is rejected as the + reference does, and — matching the reference — the begin boundary that was + already applied is left in place, so span computation extends that unmatched + begin over the text that follows it; +- an unmatched begin is distinguished from a zero-length mark (whose end + operation exists but is ordered before the begin) by checking that the + operation following the begin is really a mark end, so a later unrelated + operation reusing that counter is not mistaken for it. + +These closed the common cases. The dangling begin the reference leaves behind +when a mark is applied with an out-of-range end boundary is now handled in full. +The mark call fails, but the begin was already recorded, and it then covers text +according to its expand direction. A leftward-expanding begin (expand "before" or +"both") sorts after same-anchor insertions by descending operation ID, so its +walk index lands past text it should cover; `richTextMarks` starts such a +dangling begin at the position just after the begin's own anchor element (or the +document start for a head anchor) rather than at its walk index. + +The last remaining divergences were not in span computation but in authoring: a +split block did not resolve its insertion anchor against neighbouring mark +boundaries the way a text insertion does. A block inserted next to a dangling +begin therefore landed on the wrong side of it, and every insertion anchored +after that block inherited the mistake, so the marks the following text carried +diverged from the reference in both directions (a mark dropped, or a mark leaking +past a block). `SplitBlock` now resolves its anchor through the same insert query +as `Splice`. + +`TestRustText_DanglingMarkBoundaries` gates eleven delta-debugged reproducers, +and `TestRustText_MarkValuesMatchReferenceUnderErrors` compares marked spans run +for run against the reference across two thousand randomized scenarios that +include out-of-range boundaries and every expand mode. A wider sweep of +twenty-four thousand scenarios across six seeds, up to seventeen steps each, found +no divergence. + +**Concurrent re-encoding is now byte-identical.** Assigning the value a key +already resolves to used to skip writing an operation. That is correct for an +ordinary key, but a key holding concurrent values has to collapse: the reference +deletes the losing siblings and keeps the winner. Native left the conflict +standing, so the engines then disagreed about which values were visible at that +key, emitted different predecessors on later deletes, and produced different +change bytes and hashes. Both the map and list paths now emit that delete. +`TestConcurrentEncodingIsByteIdentical` asserts that two peers per engine encode +every concurrent change to identical bytes and agree on heads; it holds across +three thousand seeds where it previously failed within a few hundred, and +`TestDifferentialStress_ConcurrentMerge` now compares whole documents including +heads rather than materialized values alone. + +## 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 document-chunk encoding, what Save now writes (done); +- 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 new file mode 100644 index 0000000000..5cc1f027e2 --- /dev/null +++ b/pkg/automerge/README.md @@ -0,0 +1,136 @@ +# Automerge + +This package is Probo's owned, no-CGO boundary for Automerge documents. + +## Engines + +The default backend is a clean-room, pure-Go Automerge 0.10 engine. It: + +- decodes document, change, compressed-change, and v1/v2 sync formats; +- validates checksums, actor ownership, causal frontiers, sequences, and limits; +- preserves unknown columns and scalars for forward compatibility; +- supports maps, text, UTF-16 splices, rich-text materialization, cursors, + changes, heads, merges, and synchronization; and +- emits changes accepted by official Rust and JavaScript implementations. + +The package also retains a first-party WASI adapter around the official +[`automerge`](https://crates.io/crates/automerge) Rust crate as an independent +differential oracle. The adapter: + +- pins `automerge` 0.10.0 and every transitive crate in `Cargo.lock`; +- compiles with UTF-16 indexing to match the JavaScript editor; +- runs in-process through wazero without CGO or native shared libraries; and +- gives every open document an isolated WASM instance. + +Use `NewReference` and `LoadReference` only in conformance tests or when +diagnosing native parity. Production `New` and `Load` use the Go engine. + +The committed `reference.wasm` is reproducible from reviewed Rust source: + +```sh +rustup toolchain install 1.89.0 --profile minimal --target wasm32-wasip1 +make generate-automerge-reference +``` + +## Compatibility checks + +Ordinary Go tests cover binary round trips, UTF-16 text offsets, concurrent +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, JavaScript documents in Go, and verifies that +JavaScript preserves the exact hashes and bytes of Go-generated changes: + +```sh +npm install +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 new file mode 100644 index 0000000000..d312f43401 --- /dev/null +++ b/pkg/automerge/automerge.go @@ -0,0 +1,1233 @@ +// 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 provides a no-CGO Go API for Automerge documents. +// +// The initial engine embeds the official Rust Automerge engine as a WASI +// module. A native Go engine can implement the private engine contract and be +// checked against this reference implementation without changing callers. +package automerge + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "go.probo.inc/probo/pkg/automerge/internal/native" + "go.probo.inc/probo/pkg/automerge/internal/reference" +) + +type ( + // ActorID identifies one writer in an Automerge history. + ActorID [16]byte + + // Hash identifies an Automerge change. + Hash [32]byte + + // Cursor is a stable position in an Automerge sequence. + Cursor []byte + + // Change is one immutable encoded Automerge change. + Change struct { + Hash Hash + Bytes []byte + } + + // Document is a concurrency-safe Automerge document. + Document struct { + mu sync.Mutex + engine engine + closed bool + } + + // Text is a collaborative UTF-16-indexed text object. + Text struct { + document *Document + handle uint32 + } + + // SyncState tracks one document's synchronization with one remote peer. + SyncState struct { + document *Document + handle uint32 + closed bool + } + + engine interface { + Close(context.Context) error + Save(context.Context, bool, bool) ([]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) + } + + changeBackend interface { + ChangesSince(context.Context, [][32]byte) ([][]byte, [][32]byte, error) + } + + changeApplier interface { + ApplyChanges(context.Context, [][]byte) error + } +) + +var ( + ErrClosed = errors.New("automerge document is closed") + ErrSameDocument = errors.New("cannot merge an Automerge document into itself") + ErrSyncStateClosed = errors.New("automerge sync state is closed") + + _ engine = (*reference.Engine)(nil) + _ engine = (*native.Engine)(nil) +) + +const rootObject uint32 = 0 + +// NewActorID returns a cryptographically random actor ID. +func NewActorID() (ActorID, error) { + var actorID ActorID + if _, err := rand.Read(actorID[:]); err != nil { + return ActorID{}, fmt.Errorf("cannot generate Automerge actor ID: %w", err) + } + + return actorID, nil +} + +// New creates an empty document using the native Go engine. +func New(ctx context.Context, actorID ActorID) (*Document, error) { + b, err := native.NewEngine(ctx) + if err != nil { + return nil, fmt.Errorf("cannot create native Automerge engine: %w", err) + } + + if err := b.SetActor(ctx, actorID[:]); err != nil { + _ = b.Close(ctx) + return nil, fmt.Errorf("cannot initialize native Automerge actor: %w", err) + } + + return &Document{engine: b}, nil +} + +// NewReference creates an empty document using the official WASM reference +// engine. It exists as a differential oracle for the native engine and is +// intended for tests, not production use. +func NewReference(ctx context.Context, actorID ActorID) (*Document, error) { + b, err := reference.New(ctx) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge engine: %w", err) + } + + if err := b.SetActor(ctx, actorID[:]); err != nil { + _ = b.Close(ctx) + return nil, fmt.Errorf("cannot initialize Automerge actor: %w", err) + } + + return &Document{engine: b}, nil +} + +// LoadOption configures how Load interprets stored data. +type LoadOption func(*loadConfig) + +type loadConfig struct { + convertStringsToText bool +} + +// ConvertStringsToText converts every string scalar stored in a map or list +// into a text object as the document loads, mirroring Rust's +// StringMigration::ConvertToText load option. +func ConvertStringsToText() LoadOption { + return func(c *loadConfig) { c.convertStringsToText = true } +} + +// Load creates a document from stored data using the native Go engine and +// assigns a new writer. +func Load( + ctx context.Context, + data []byte, + actorID ActorID, + options ...LoadOption, +) (*Document, error) { + config := loadConfig{} + for _, option := range options { + option(&config) + } + + b, err := native.LoadEngine(ctx, data) + if err != nil { + return nil, fmt.Errorf("cannot load native Automerge engine: %w", err) + } + + if err := b.SetActor(ctx, actorID[:]); err != nil { + _ = b.Close(ctx) + return nil, fmt.Errorf("cannot assign native Automerge actor: %w", err) + } + + document := &Document{engine: b} + + if config.convertStringsToText { + if err := document.convertStringsToText(ctx); err != nil { + _ = document.Close(ctx) + + return nil, err + } + } + + return document, nil +} + +// LoadReference loads a document using the official WASM reference engine. Like +// NewReference it is intended for tests, not production use. +func LoadReference( + ctx context.Context, + data []byte, + actorID ActorID, + options ...LoadOption, +) (*Document, error) { + config := loadConfig{} + for _, option := range options { + option(&config) + } + + load := reference.Load + if config.convertStringsToText { + // The reference applies the migration during load through its own WASM + // entry point rather than as a post-load pass. + load = reference.LoadConvertingStrings + } + + b, err := load(ctx, data) + if err != nil { + return nil, fmt.Errorf("cannot load Automerge engine: %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{engine: 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(ctx) + 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 := range length { + 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(ctx) + 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 +} + +// Close releases the engine resources held by the document. +func (d *Document) Close(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil + } + + d.closed = true + + if err := d.engine.Close(ctx); err != nil { + return fmt.Errorf("cannot close Automerge document: %w", err) + } + + return nil +} + +// SaveOption configures how Save serializes a document. +type SaveOption func(*saveConfig) + +type saveConfig struct { + retainOrphans bool + compress bool +} + +// NoCompress disables DEFLATE compression of the saved document. The default is +// to compress, which the reference's save_nocompress also opts out of; the +// uncompressed form is mainly useful for comparing sizes or debugging. +func NoCompress() SaveOption { + return func(c *saveConfig) { c.compress = false } +} + +// DiscardOrphans drops orphan changes (changes whose dependencies are missing) +// instead of retaining them. Retaining them, the default, preserves them across +// a save/load round trip so they resolve once their dependencies arrive; +// discarding drops them permanently. It mirrors Rust's SaveOptions.retain_orphans. +func DiscardOrphans() SaveOption { + return func(c *saveConfig) { c.retainOrphans = false } +} + +// Save serializes the complete Automerge history as a compacted document. By +// default it compresses and retains orphan changes; pass NoCompress or +// DiscardOrphans to change that. +func (d *Document) Save(ctx context.Context, options ...SaveOption) ([]byte, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + config := saveConfig{retainOrphans: true, compress: true} + for _, option := range options { + option(&config) + } + + data, err := d.engine.Save(ctx, config.retainOrphans, config.compress) + 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.engine.Isolate(ctx, engineHashes(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.engine.Integrate(ctx); err != nil { + return fmt.Errorf("cannot integrate Automerge document: %w", err) + } + + return 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.engine.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.engine.Save(ctx, true, true) + if err != nil { + d.mu.Unlock() + return nil, fmt.Errorf("cannot save Automerge fork source: %w", err) + } + + _, referenceBackend := d.engine.(*reference.Engine) + 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.engine.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.engine.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() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + if err := d.engine.PutString(ctx, rootObject, key, value); err != nil { + return fmt.Errorf("cannot put Automerge string: %w", err) + } + + 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.engine.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() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + handle, err := d.engine.PutText(ctx, rootObject, key) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge text: %w", err) + } + + return &Text{document: d, handle: handle}, nil +} + +// Text returns existing collaborative text from the root map. +func (d *Document) Text(ctx context.Context, key string) (*Text, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + handle, err := d.engine.GetText(ctx, rootObject, key) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge text: %w", err) + } + + return &Text{document: d, handle: handle}, nil +} + +// Commit records pending operations as one Automerge change. +func (d *Document) Commit( + 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.engine.Commit(ctx, message, timestamp) + if err != nil { + return Hash{}, fmt.Errorf("cannot commit Automerge document: %w", err) + } + + 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.engine.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.engine.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() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + engineHeads, err := d.engine.Heads(ctx) + if err != nil { + return nil, fmt.Errorf("cannot get Automerge heads: %w", err) + } + + heads := make([]Hash, len(engineHeads)) + for i := range engineHeads { + heads[i] = Hash(engineHeads[i]) + } + + 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 + } + + oracle, ok := d.engine.(*reference.Engine) + 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 oracle.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.engine.HasHeads(ctx, engineHashes(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.engine.MissingDependencies( + ctx, + engineHashes(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, + heads []Hash, +) ([]Change, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + changeSource, ok := d.engine.(changeBackend) + if !ok { + return nil, fmt.Errorf("automerge engine does not expose incremental changes") + } + + engineHeads := make([][32]byte, len(heads)) + for i, head := range heads { + engineHeads[i] = [32]byte(head) + } + + raw, hashes, err := changeSource.ChangesSince(ctx, engineHeads) + if err != nil { + return nil, fmt.Errorf("cannot get incremental Automerge changes: %w", err) + } + + if len(raw) != len(hashes) { + return nil, fmt.Errorf( + "cannot get incremental Automerge changes: %d changes for %d hashes", + len(raw), + len(hashes), + ) + } + + changes := make([]Change, len(raw)) + for i := range raw { + changes[i] = Change{ + Hash: Hash(hashes[i]), + Bytes: append([]byte(nil), raw[i]...), + } + } + + return changes, nil +} + +// ApplyChanges applies encoded changes whose dependencies may already exist in +// the document. +func (d *Document) ApplyChanges( + ctx context.Context, + changes []Change, +) error { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return ErrClosed + } + + applier, ok := d.engine.(changeApplier) + if !ok { + return fmt.Errorf("automerge engine does not accept incremental changes") + } + + raw := make([][]byte, len(changes)) + for i, change := range changes { + raw[i] = change.Bytes + } + + if err := applier.ApplyChanges(ctx, raw); err != nil { + return fmt.Errorf("cannot apply incremental Automerge changes: %w", err) + } + + return nil +} + +// Merge applies all changes from another document. +func (d *Document) Merge(ctx context.Context, other *Document) ([]Hash, error) { + if d == other { + return nil, ErrSameDocument + } + + otherData, err := other.Save(ctx) + if err != nil { + return nil, fmt.Errorf("cannot save Automerge merge source: %w", err) + } + + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + engineHeads, err := d.engine.Merge(ctx, otherData) + if err != nil { + return nil, fmt.Errorf("cannot merge Automerge document: %w", err) + } + + heads := make([]Hash, len(engineHeads)) + for i := range engineHeads { + heads[i] = Hash(engineHeads[i]) + } + + return heads, nil +} + +// NewSyncState starts synchronization with a remote peer. +func (d *Document) NewSyncState(ctx context.Context) (*SyncState, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + handle, err := d.engine.NewSyncState(ctx) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge sync state: %w", err) + } + + return &SyncState{document: d, handle: handle}, nil +} + +// LoadSyncState resumes a previously serialized remote-peer session. +func (d *Document) LoadSyncState(ctx context.Context, data []byte) (*SyncState, error) { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return nil, ErrClosed + } + + handle, err := d.engine.LoadSyncState(ctx, data) + if err != nil { + return nil, fmt.Errorf("cannot load Automerge sync state: %w", err) + } + + return &SyncState{document: d, handle: handle}, nil +} + +// Splice replaces deleteCount UTF-16 code units at index with value. +func (t *Text) Splice(ctx context.Context, index uint32, deleteCount int32, value string) error { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return ErrClosed + } + + if err := t.document.engine.SpliceText(ctx, t.handle, index, deleteCount, value); err != nil { + return fmt.Errorf("cannot splice Automerge text: %w", err) + } + + 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.engine.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() + defer t.document.mu.Unlock() + + if t.document.closed { + return "", ErrClosed + } + + value, err := t.document.engine.Text(ctx, t.handle) + if err != nil { + return "", fmt.Errorf("cannot read Automerge text: %w", err) + } + + 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.engine.TextAt( + ctx, + t.handle, + engineHashes(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() + defer t.document.mu.Unlock() + + if t.document.closed { + return nil, ErrClosed + } + + cursor, err := t.document.engine.TextCursor(ctx, t.handle, index) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge text cursor: %w", err) + } + + return Cursor(cursor), nil +} + +// CursorPosition resolves a stable cursor in the current document. +func (t *Text) CursorPosition(ctx context.Context, cursor Cursor) (uint32, error) { + t.document.mu.Lock() + defer t.document.mu.Unlock() + + if t.document.closed { + return 0, ErrClosed + } + + position, err := t.document.engine.TextCursorPosition(ctx, t.handle, cursor) + if err != nil { + return 0, fmt.Errorf("cannot resolve Automerge text cursor: %w", err) + } + + return position, nil +} + +// Close releases the peer-specific synchronization state. +func (s *SyncState) Close(ctx context.Context) error { + s.document.mu.Lock() + defer s.document.mu.Unlock() + + if s.closed { + return nil + } + + s.closed = true + + if s.document.closed { + return nil + } + + if err := s.document.engine.CloseSyncState(ctx, s.handle); err != nil { + return fmt.Errorf("cannot close Automerge sync state: %w", err) + } + + return nil +} + +// GenerateMessage returns the next message for the remote peer. +func (s *SyncState) GenerateMessage(ctx context.Context) ([]byte, bool, error) { + s.document.mu.Lock() + defer s.document.mu.Unlock() + + if s.document.closed { + return nil, false, ErrClosed + } + + if s.closed { + return nil, false, ErrSyncStateClosed + } + + message, ok, err := s.document.engine.GenerateSyncMessage(ctx, s.handle) + if err != nil { + return nil, false, fmt.Errorf("cannot generate Automerge sync message: %w", err) + } + + return message, ok, nil +} + +// ReceiveMessage applies a message received from the remote peer. +func (s *SyncState) ReceiveMessage(ctx context.Context, message []byte) 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.engine.ReceiveSyncMessage(ctx, s.handle, message); err != nil { + return fmt.Errorf("cannot receive Automerge sync message: %w", err) + } + + 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.engine.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.engine.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() + defer s.document.mu.Unlock() + + if s.document.closed { + return nil, ErrClosed + } + + if s.closed { + return nil, ErrSyncStateClosed + } + + data, err := s.document.engine.SaveSyncState(ctx, s.handle) + if err != nil { + return nil, fmt.Errorf("cannot save Automerge sync state: %w", err) + } + + return data, nil +} + +// String returns the lowercase hexadecimal change hash. +func (h Hash) String() string { + return hex.EncodeToString(h[:]) +} + +// String returns the lowercase hexadecimal actor ID. +func (a ActorID) String() string { + return hex.EncodeToString(a[:]) +} diff --git a/pkg/automerge/automerge_test.go b/pkg/automerge/automerge_test.go new file mode 100644 index 0000000000..42daaed144 --- /dev/null +++ b/pkg/automerge/automerge_test.go @@ -0,0 +1,339 @@ +// 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" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +var commitTime = time.Date(2026, time.August, 7, 12, 0, 0, 0, time.UTC) + +func actor(value byte) automerge.ActorID { + var actorID automerge.ActorID + + actorID[0] = value + + return actorID +} + +func closeDocument(t *testing.T, document *automerge.Document) { + t.Helper() + t.Cleanup( + func() { + require.NoError(t, document.Close(context.Background())) + }, + ) +} + +func closeSyncState(t *testing.T, state *automerge.SyncState) { + t.Helper() + t.Cleanup( + func() { + require.NoError(t, state.Close(context.Background())) + }, + ) +} + +func synchronize(t *testing.T, left, right *automerge.SyncState) { + t.Helper() + + ctx := context.Background() + + for range 100 { + progressed := false + + message, ok, err := left.GenerateMessage(ctx) + require.NoError(t, err) + + if ok { + require.NoError(t, right.ReceiveMessage(ctx, message)) + + progressed = true + } + + message, ok, err = right.GenerateMessage(ctx) + require.NoError(t, err) + + if ok { + require.NoError(t, left.ReceiveMessage(ctx, message)) + + progressed = true + } + + if !progressed { + return + } + } + + require.Fail(t, "sync did not quiesce") +} + +func newBaseDocument(t *testing.T) []byte { + t.Helper() + + ctx := context.Background() + document, err := automerge.NewReference(ctx, actor(1)) + 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, "Hello")) + _, err = document.Commit(ctx, "Create document", commitTime) + require.NoError(t, err) + + data, err := document.Save(ctx) + require.NoError(t, err) + + return data +} + +func TestDocument_SaveAndLoad(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + require.NoError(t, document.PutString(ctx, "title", "Policy")) + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Hello")) + + hash, err := document.Commit(ctx, "Create policy", commitTime) + require.NoError(t, err) + assert.Len(t, hash.String(), 64) + + data, err := document.Save(ctx) + require.NoError(t, err) + + loaded, err := automerge.Load(ctx, data, actor(2)) + require.NoError(t, err) + closeDocument(t, loaded) + + loadedText, err := loaded.Text(ctx, "body") + require.NoError(t, err) + value, err := loadedText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Hello", value) + + heads, err := loaded.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, []automerge.Hash{hash}, heads) +} + +func TestDocument_ConcurrentChangesConverge(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base := newBaseDocument(t) + + left, err := automerge.Load(ctx, base, actor(2)) + require.NoError(t, err) + closeDocument(t, left) + leftText, err := left.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, leftText.Splice(ctx, 5, 0, " left")) + _, err = left.Commit(ctx, "Edit left", commitTime.Add(time.Second)) + require.NoError(t, err) + + right, err := automerge.Load(ctx, base, actor(3)) + require.NoError(t, err) + closeDocument(t, right) + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, rightText.Splice(ctx, 5, 0, " right")) + _, err = right.Commit(ctx, "Edit right", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + leftData, err := left.Save(ctx) + require.NoError(t, err) + rightData, err := right.Save(ctx) + require.NoError(t, err) + + leftFirst, err := automerge.Load(ctx, leftData, actor(4)) + require.NoError(t, err) + closeDocument(t, leftFirst) + + rightFirst, err := automerge.Load(ctx, rightData, actor(5)) + require.NoError(t, err) + closeDocument(t, rightFirst) + + _, err = leftFirst.Merge(ctx, right) + require.NoError(t, err) + _, err = rightFirst.Merge(ctx, left) + require.NoError(t, err) + + leftMergedText, err := leftFirst.Text(ctx, "body") + require.NoError(t, err) + rightMergedText, err := rightFirst.Text(ctx, "body") + require.NoError(t, err) + leftValue, err := leftMergedText.String(ctx) + require.NoError(t, err) + rightValue, err := rightMergedText.String(ctx) + require.NoError(t, err) + assert.Equal(t, leftValue, rightValue) + + leftHeads, err := leftFirst.Heads(ctx) + require.NoError(t, err) + rightHeads, err := rightFirst.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, leftHeads, rightHeads) +} + +func TestText_SpliceUsesUTF16Offsets(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(1)) + 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😀B")) + require.NoError(t, text.Splice(ctx, 1, 2, "")) + + value, err := text.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AB", value) +} + +func TestText_CursorTracksConcurrentEdits(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.Load(ctx, newBaseDocument(t), actor(2)) + require.NoError(t, err) + closeDocument(t, document) + text, err := document.Text(ctx, "body") + require.NoError(t, err) + + cursor, err := text.Cursor(ctx, 4) + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "A")) + _, err = document.Commit(ctx, "Insert prefix", commitTime.Add(time.Second)) + require.NoError(t, err) + + position, err := text.CursorPosition(ctx, cursor) + require.NoError(t, err) + assert.Equal(t, uint32(5), position) +} + +func TestSyncState_ExchangesConcurrentChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + left, err := automerge.Load(ctx, newBaseDocument(t), actor(2)) + require.NoError(t, err) + closeDocument(t, left) + + right, err := automerge.New(ctx, actor(3)) + require.NoError(t, err) + closeDocument(t, right) + + leftSync, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftSync) + + rightSync, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightSync) + + synchronize(t, leftSync, rightSync) + + leftText, err := left.Text(ctx, "body") + require.NoError(t, err) + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, leftText.Splice(ctx, 5, 0, " left")) + _, err = left.Commit(ctx, "Edit left", commitTime.Add(time.Second)) + require.NoError(t, err) + require.NoError(t, rightText.Splice(ctx, 5, 0, " right")) + _, err = right.Commit(ctx, "Edit right", commitTime.Add(2*time.Second)) + require.NoError(t, err) + + synchronize(t, leftSync, rightSync) + + leftValue, err := leftText.String(ctx) + require.NoError(t, err) + rightValue, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, leftValue, rightValue) + + leftHeads, err := left.Heads(ctx) + require.NoError(t, err) + rightHeads, err := right.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, leftHeads, rightHeads) +} + +func TestSyncState_SaveAndLoad(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + state, err := document.NewSyncState(ctx) + require.NoError(t, err) + data, err := state.Save(ctx) + require.NoError(t, err) + require.NoError(t, state.Close(ctx)) + + loaded, err := document.LoadSyncState(ctx, data) + require.NoError(t, err) + closeSyncState(t, loaded) + _, _, err = loaded.GenerateMessage(ctx) + require.NoError(t, err) +} + +func TestDocument_CloseIsIdempotent(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + + require.NoError(t, document.Close(ctx)) + require.NoError(t, document.Close(ctx)) + + _, err = document.Save(ctx) + assert.ErrorIs(t, err, automerge.ErrClosed) +} + +func TestLoad_InvalidDocument(t *testing.T) { + t.Parallel() + + document, err := automerge.Load(context.Background(), []byte("invalid"), actor(1)) + assert.Nil(t, document) + assert.Error(t, err) + assert.False(t, errors.Is(err, automerge.ErrClosed)) +} 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..1f858fcb22 --- /dev/null +++ b/pkg/automerge/batch_insert_patch_parity_test.go @@ -0,0 +1,180 @@ +// 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..4f864df218 --- /dev/null +++ b/pkg/automerge/block_spans_parity_test.go @@ -0,0 +1,507 @@ +// 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/collaboration/GATEWAY_CONTRACT.md b/pkg/automerge/collaboration/GATEWAY_CONTRACT.md new file mode 100644 index 0000000000..05abf8c8cf --- /dev/null +++ b/pkg/automerge/collaboration/GATEWAY_CONTRACT.md @@ -0,0 +1,166 @@ +# Repo collaboration gateway contract + +This is the integration contract for migrating Probo's real-time document editing +from the custom `automerge-sync-v1` WebSocket protocol to the official +`@automerge/automerge-repo` protocol. It pins the three decisions the gateway and +the frontend must agree on — **document id**, **auth**, and **seeding** — plus the +rollout shape. The transport-agnostic protocol drivers it builds on +(`ServerConn`, `ClientConn`), the hub fan-out primitives, and the presence value +already exist and are tested; see [`PROTOCOL.md`](PROTOCOL.md). + +The custom client being replaced lives in +`apps/console/src/pages/organizations/documents/description/_lib/AutomergeDocumentHandle.ts` +(transport) and `packages/ui/src/RichEditor/*` (the ProseMirror ↔ Automerge +binding and presence decorations). The binding layer is reusable as long as the +new client still exposes an `@automerge/prosemirror` `DocHandle`; only the +transport is replaced. + +## 1. Routing and scope + +Keep the connection **version-scoped by URL path**, exactly as the custom route +is: + +``` +{ws|wss}://{host}/api/console/v1/document-versions/{documentVersionID}/repo +``` + +- The `documentVersionID` path segment (a Probo `DocumentVersion` GID) is the + single source of truth for **authorization** and **room selection**. It is not + the repo document id. +- A new `/repo` route is added next to the existing `/sync` route so the two + protocols coexist during migration. Mount it in the same authenticated router + group in `pkg/server/api/console/v1/resolver.go`. +- One WebSocket serves exactly one document version, matching the hub's + one-room-per-version model (`hub.acquire(scope, documentVersionID, ...)`). + +Rationale: the repo protocol has no notion of "which Probo resource is this"; the +path keeps auth and room routing on the proven code path and out of the protocol. + +## 2. Document id + +The repo document id is **derived deterministically from the version GID**, not +chosen freely: + +``` +documentID = base58check( sha256(documentVersionID)[:16] ) // DeriveDocumentID +automergeURL = "automerge:" + documentID // DeriveAutomergeURL +``` + +- `DeriveDocumentID` / `DeriveAutomergeURL` (this package, `documentid.go`) are + the canonical Go implementation; the frontend must implement the identical + derivation in TypeScript (same SHA-256, first 16 bytes, bs58check). The Go + codec is validated against a real `@automerge/automerge-repo` id, so the two + will agree. +- **Determinism is required, not cosmetic.** All peers of a version must use the + same id because ephemeral gossip (presence, cursors) is keyed by document id: a + peer silently drops an ephemeral frame whose id it does not recognise. Sync + alone would tolerate differing ids (the server re-tags per connection), but + presence would not. +- The server needs **no** id-derivation logic: it uses `NewAdoptingServerConn`, + which binds to whatever id the client requests in its first sync/request frame + and answers for that id (rejecting any second id on the connection). Go agents + that want to open a version compute the URL with `DeriveAutomergeURL(gid)`. + +Rationale: derivation gives every browser tab and Go agent the same id with zero +coordination and no new storage, and it makes presence line up. + +## 3. Authentication + +Auth is **unchanged** from the custom route; the repo `PeerId` is never trusted. + +- **Browser** — a same-origin WebSocket upgrade carries the session cookie + automatically. The `/repo` route sits behind the existing middleware + (`NewSessionMiddleware`, `NewAPIKeyMiddleware`, `NewOAuth2AccessTokenMiddleware`, + `NewIdentityPresenceMiddleware`) and the same `authorize` check + (`DocumentVersionGet` + `DocumentUpdate`). No token is placed in the URL. +- **Go agents / non-browser clients** — present an API key or bearer token as an + `Authorization` header on the upgrade request (the WebSocket dialer sets request + headers). The same middleware validates it. +- The authenticated identity is bound to the connection server-side and used for + audit and presence attribution. The repo `senderId` in the `join` frame is + peer-chosen and is treated purely as a routing label, never as identity. + +Rationale: reuses the working auth path and keeps credentials out of URLs and out +of the peer-chosen protocol fields. + +## 4. Seeding + +The server is the **document authority and owns seeding**; there is no seed +field in the repo handshake, and clients never seed via the protocol. + +- A collaboration connection is served a materialized Automerge document for the + version. The custom `needsSeed` / `seedContent` / seed-owner handshake and + `ReleaseCollaborationSeed` machinery are **dropped** from the repo path. +- **Implementation (done)** — the conversion was ported to Go + (`pkg/automerge/prosemirror.ToSpans`, the inverse of `Render`, validated to + round-trip the entire shared ProseMirror corpus). The `/repo` handler seeds + lazily on first open: the connection that claims the seed converts the + version's stored ProseMirror JSON to spans, writes them into the shared + document, and persists; the persist marks the state seeded, so later + connections skip it. No JavaScript build step and no draft-creation change are + required. + +Rationale: server-authoritative seeding matches the CRDT authority model and +removes fragile handshake state; the only real work is where the one-time +PM → spans conversion runs. + +## 5. Presence and cursors + +- Presence rides repo **ephemeral gossip**, not a side JSON channel. The server + returns a non-duplicate ephemeral frame from `ServerConn.Receive` as `fanout` + and publishes it with `lease.BroadcastEphemeral`; it writes other peers' frames + from `lease.Ephemeral` to the socket. +- A caret/selection is a presence `update` whose value is a `TextSelectionValue` + (this package): the addressed text field plus **stable Automerge cursors** for + anchor and head, replacing the custom integer `anchorPosition`/`headPosition`. + Cursors survive concurrent edits; offsets do not. The frontend builds them with + `Text.getCursor` and resolves them with `Text.getCursorPosition`; the presence + decorations in `packages/ui/src/RichEditor/presence.ts` render from the resolved + positions. +- **Cross-instance ephemeral** is delivered by publishing the frame over the + collaboration `NOTIFY` channel in a typed envelope + (`realtime.CollaborationEphemeral`) alongside the bare version-id "changed" + signal. The `/repo` loop relays each gossiped frame with + `DocumentService.NotifyCollaborationEphemeral`; the receiving instance decodes + the envelope in `notifyExternal` and fans it out to local peers, skipping the + publishing instance's own echo. Oversized frames fall back to local-only + fan-out. + +## 6. Reconnect and revision + +- The repo network adapter owns reconnect/backoff and the sync generation model, + so the custom `revision` / `ready` / initialization-timeout handshake fields are + dropped from the client. +- The server keeps its debounced persistence and its cross-instance sync refresh + (`lease.SchedulePersist`, `lease.Wake`, `RefreshCollaboration`) unchanged; those + are independent of the wire protocol. + +## 7. Rollout + +1. Add the `/repo` route wired to `NewAdoptingServerConn` + the hub, coexisting + with `/sync`. +2. Add the TS `deriveDocumentId` helper (mirroring `DeriveDocumentID`) and a repo + `NetworkAdapter` targeting `/repo`, behind a feature flag, reusing the existing + `DocHandle`-based binding. +3. Validate with a Postgres-backed integration test and the live JS interop + harness, then flip the flag. +4. Remove `/sync`, `AutomergeDocumentHandle.ts`, and the DB presence tables once + no client uses the custom protocol. + +## Status + +| Piece | State | +|---|---| +| `ServerConn` / `ClientConn` drivers | done, tested | +| Adopting document id (`NewAdoptingServerConn`) | done, tested | +| Document id derivation + `automerge:` URL (`documentid.go`) | done, tested against a real repo id | +| Opaque ephemeral fan-out (`BroadcastEphemeral` / `Ephemeral`) | done, tested | +| Cursor-based selection presence (`TextSelectionValue`) | done, tested | +| TS `deriveDocumentId` mirror (`@probo/ui`) | done, tested for byte-parity with Go | +| TS cursor-based selection helper (`@probo/ui` `repoSelection`) | done, tested for cursor stability across concurrent edits (browser↔browser) | +| `/repo` route wiring | done, driven end-to-end in Go by a real `ClientConn` (Postgres integration + live JS still pending) | +| ProseMirror → spans forward conversion (`prosemirror.ToSpans`) | done, round-trips the whole shared corpus | +| Server-authoritative seeding | done, seeds on first open from stored content and materializes over the loop (Postgres lifecycle still to integration-test) | +| Cross-instance ephemeral fan-out | done, published over the NOTIFY channel and delivered to local peers with self-echo suppression (Postgres delivery still to integration-test) | +| Frontend repo client (`connectRepoDocument`) | done, `Repo` + `WebSocketClientAdapter` over `/repo`; presence rides repo ephemeral with stable cursors (PM↔Automerge mapping unit-tested) | +| Legacy `/sync` removal | done: `/sync` route, the custom `AutomergeDocumentHandle`, the DB-backed presence, and the hub's structured presence are removed; the editor uses `/repo` exclusively | diff --git a/pkg/automerge/collaboration/PROTOCOL.md b/pkg/automerge/collaboration/PROTOCOL.md new file mode 100644 index 0000000000..3763009731 --- /dev/null +++ b/pkg/automerge/collaboration/PROTOCOL.md @@ -0,0 +1,281 @@ +# automerge-repo protocol inventory + +This package makes the Probo Go server and Go agents speak the same collaboration +protocol as the JavaScript `@automerge/automerge-repo` client, while keeping our +own pure-Go CRDT engine (`pkg/automerge`) as the document authority. + +Everything here is derived from the pinned upstream source, not from guesswork. +The wire format upstream ships is **alpha**, so the pinned version is the +contract and the JS oracle fixtures under `testdata/` are the ground truth. No +Go decoder or encoder is trusted until it round-trips those fixtures. + +## Pinned versions + +| Package | Version | Role | +|---|---|---| +| `@automerge/automerge` | `3.4.1` (`^3.4.0`) | CRDT core; sync message and cursor bytes | +| `@automerge/automerge-repo` | `2.6.0-alpha.3` (exact) | repo message union, ephemeral gossip, Presence | +| `@automerge/automerge-repo-network-websocket` | to pin in the transport phase | WebSocket wire framing and join/peer/leave handshake | + +The websocket adapter is a separate package and defines the actual on-socket +framing. It is intentionally out of scope for this first inventory, which covers +the message layer and Presence; the transport phase pins and inventories it. + +## Layering + +A byte on the socket nests three independently versioned layers: + +```text +WebSocket adapter frame (join / peer / leave + CBOR of the repo message) + └── repo message (sync | request | ephemeral | doc-unavailable | ...) + └── payload + ├── sync/request: an Automerge V2 sync message (our engine owns this) + └── ephemeral: CBOR of the Presence envelope +``` + +Our engine already owns the innermost layer. This package adds the middle layer +and Presence; the transport phase adds the outermost. + +## Repo message union + +From `dist/network/messages.d.ts`. Every message carries `senderId` and +`targetId` (both `PeerId`, a string). Type-specific fields: + +| `type` | Fields | Notes | +|---|---|---| +| `sync` | `documentId`, `data: Uint8Array` | `data` is an Automerge sync message | +| `request` | `documentId`, `data: Uint8Array` | initial sync asking whether a peer has the doc | +| `ephemeral` | `documentId`, `sessionId`, `count: number`, `data: Uint8Array` | gossiped; not persisted | +| `doc-unavailable` | `documentId` | neither the peer nor its peers have the doc | +| `remote-subscription-change` | `add?`, `remove?` (`StorageId[]`) | storage-head subscription control | +| `remote-heads-changed` | `documentId`, `newHeads` | per-storage heads with timestamps | + +For our use case the document-scoped subset (`DocMessage`) is what matters: +`sync`, `request`, `ephemeral`, `doc-unavailable`. The two remote-heads messages +support cross-storage head subscription, which our single-authority server does +not need initially; the gateway may ignore them (documented, not silently). + +## Ephemeral gossip and de-duplication + +Ephemeral messages are forwarded ("gossiped") to peers, so the protocol dedupes +by `(sessionId, count)`: + +- `sessionId` is a random id chosen by a sender at startup. +- `count` is a per-sender sequence number that strictly increases. +- A receiver discards any `(sessionId, count)` it has already seen, which breaks + forwarding loops. + +The gateway must preserve `senderId`, `sessionId`, and `count` unchanged when +relaying, and must apply the same de-duplication before re-broadcasting. + +## Presence envelope + +Presence (`dist/presence/`) rides entirely inside the ephemeral `data`. It never +touches document history. The `data` bytes are the CBOR encoding of a single-key +envelope: + +```jsonc +{ "__presence": } +``` + +The marker key is `__presence` (`PRESENCE_MESSAGE_MARKER`). The four presence +messages: + +| `type` | Fields | Meaning | +|---|---|---| +| `update` | `channel: string`, `value: any` | one channel's state changed | +| `snapshot` | `state: any` | full multi-channel state (sent on start and to newcomers) | +| `heartbeat` | — | liveness when nothing changed | +| `goodbye` | — | sender is leaving; forget it immediately | + +Defaults (`dist/presence/constants.js`): + +- heartbeat interval: `15000 ms` +- peer TTL: `45000 ms` (three missed heartbeats) + +A peer is pruned once `peerTtlMs` passes with no message; `goodbye` prunes +immediately. `value`/`state` are application-defined; our documents will carry an +Automerge `Cursor` (opaque bytes) for selections rather than integer offsets. + +## CBOR encoding + +The ephemeral payload uses `cbor-x` configured as: + +```js +new Encoder({ tagUint8Array: false, useRecords: false }) +``` + +This matters for byte-level parity, and the Go codec must match it: + +- `useRecords: false` — plain CBOR maps (major type 5), not cbor-x record + extensions. No custom tags for object shapes. +- `tagUint8Array: false` — a `Uint8Array` is a plain CBOR byte string (major + type 2), not wrapped in a typed-array tag. +- Map key order follows insertion order of the JS object; a tolerant decoder + must not depend on key order, and the encoder should reproduce upstream order + where a fixture asserts byte identity. + +## Fixtures + +`testdata/` holds JS-generated fixtures produced by +`packages/automerge-conformance/generate-collaboration-fixtures.mjs` using the +pinned packages' own CBOR encoder, so they are byte-exact: + +- `presence-*.json` — each presence message type: the JS envelope plus its + base64 CBOR `data` bytes. +- `ephemeral-*.json` — a full ephemeral repo message wrapping a presence payload. + +Each Go codec change must round-trip these. The wire-framing fixtures (join/peer +handshake, socket frames) are added in the transport phase alongside the pinned +websocket adapter. + +## Transport layer (WebSocket adapter) + +Pinned: `@automerge/automerge-repo-network-websocket@2.6.0-alpha.3`. The current +`WebSocketClientAdapter` and server adapters encode every frame with the same +repo CBOR helper used for payloads (`useRecords: false`, `tagUint8Array: false`). +The older `encoder.js` and `WSShared.js` in that package are legacy compat and +are not used by the current adapters. + +Each binary WebSocket frame is exactly one CBOR-encoded message; the WebSocket +message boundary is the framing, so there is no length prefix of our own. The +server must read binary frames (not text) and treat each as one message. + +Protocol version is `"1"` (`ProtocolV1`). + +### Handshake + +```text +client ──▶ join { type:"join", senderId, peerMetadata, supportedProtocolVersions:["1"] } +server ──▶ peer { type:"peer", senderId, targetId, peerMetadata, selectedProtocolVersion:"1" } +``` + +- `join` is the first frame the client sends, before it knows the server peer id, + so it has no `targetId`. +- The server replies `peer` selecting a protocol version, or `error` + `{ type:"error", senderId, targetId, message }` and then closes the socket. +- After the handshake, both directions exchange the repo messages from the + message-union section (`sync`, `request`, `ephemeral`, `doc-unavailable`, and + the two remote-heads messages), each as its own CBOR frame. +- There is no explicit `leave` frame; a disconnect is the socket closing. A + presence `goodbye` (inside an ephemeral) is the graceful application-level + signal. + +### PeerMetadata + +```text +{ storageId?: string (StorageId), isEphemeral?: boolean } +``` + +Both fields are optional. `isEphemeral` marks a peer that does not persist +documents. Our gateway can present its own metadata and must not trust a peer's +metadata as identity (see below). + +### Sync choreography (server as authority) + +The server WebSocket adapter is a pure relay: it performs the handshake and then +hands every message to the repo's synchronizer. The synchronizer, not the +adapter, runs the sync protocol, so a gateway that is itself the document +authority must reproduce the synchronizer's server-side behavior. From +`DocSynchronizer`: + +- Inbound `sync` and `request` are handled identically: apply the payload with + `receiveSyncMessage`, then generate outbound messages. +- The message type the synchronizer emits is `request` only when it does **not** + have the document (no heads, empty shared heads, peer status unknown); + otherwise it emits `sync`. Our gateway always holds the document, so it + **always emits `sync`** and never `request`. +- `doc-unavailable` is emitted only when the responder has no data and + availability settles unavailable. Our gateway always has the document (access + is decided by authentication at connect time, returning 404/403 rather than a + protocol frame), so it does **not** send `doc-unavailable`. +- The payload bytes are exactly `generateSyncMessage`/`receiveSyncMessage` from + `@automerge/automerge`, which is the same V2 sync protocol + `pkg/automerge.SyncState` implements, so repo `sync.data` is one of our sync + messages unchanged. This is the interop linchpin and is covered by the existing + sync parity suite. + +The resulting server loop: on connect, announce by draining +`GenerateMessage` into `sync` frames; on each inbound `sync`/`request`, call +`ReceiveMessage` then drain `GenerateMessage` into `sync` frames; forward +non-duplicate `ephemeral` frames to the room and room frames to the socket. + +### Gateway responsibilities (transport) + +- Accept a binary WebSocket, read `join`, negotiate `"1"`, reply `peer` with a + server `senderId`. +- The repo `PeerId` in `join` is peer-chosen and is **not** a user identity; + authenticate the connection out of band (our existing session auth) and bind + the authenticated identity to the connection, never to `senderId`. +- Route `sync`/`request` payloads into the per-peer, per-document + `automerge.SyncState`; forward `ephemeral` frames to the room with the existing + cross-instance fanout, de-duplicated by `(sessionId, count)`. + +## Fixtures + +`testdata/` also holds transport fixtures generated from the pinned adapter's own +CBOR encoder: + +- `wire-join.json`, `wire-peer.json`, `wire-error.json` — the handshake frames. +- `wire-sync.json`, `wire-ephemeral.json` — a framed document message. + +## Gateway wiring + +The protocol drivers (`ServerConn`, `ClientConn`) are transport-agnostic and +fully tested. Wiring them into the authenticated production WebSocket endpoint +reuses the existing collaboration hub rather than duplicating rooms, persistence, +or cross-instance notification: + +- **Auth** — mount the repo route inside the same authenticated router group as + `/document-versions/{documentVersionID}/sync` and reuse + `documentCollaborationHandler.authorize`. The repo `PeerId` is never trusted as + identity. +- **Document authority & persistence** — `hub.acquire` yields a lease over the + shared `*automerge.Document`; each connection uses its own + `Document.NewSyncState`. Sync fan-out reuses `lease.NotifyPeers`/`lease.Wake` + and persistence reuses `lease.SchedulePersist`/`lease.PersistError`, exactly as + the legacy handler does. +- **Document id** — the frontend chooses the `automerge:` URL, so the gateway + uses `NewAdoptingServerConn`: it announces nothing on `Start` and binds to the + id in the client's first `sync`/`request` frame, then answers for that id and + rejects any other. This removes the need for a server/frontend id-derivation + contract. +- **Ephemeral (presence/cursors)** — repo presence travels as opaque `ephemeral` + frames, not the legacy structured snapshots. `ServerConn.Receive` returns a + non-duplicate ephemeral frame as `fanout`; the handler publishes it with + `lease.BroadcastEphemeral`, and reads other peers' frames from + `lease.Ephemeral` to write to its socket. This is scoped to one server + instance. +- **Selections/carets** — a caret or selection is published as a presence + `update` whose value is a `TextSelectionValue`: the addressed text field plus a + stable Automerge anchor and head cursor (the bytes from `Text.Cursor`), never + integer offsets. Offsets drift when anyone types before the caret; a cursor + resolves (via `Text.CursorPosition`) to the same character after arbitrary + concurrent edits, so remote carets stay anchored. The presence layer only + transports the cursor bytes, keeping it independent of the CRDT engine; the + server and Go agents create and resolve the cursors. + +### Remaining contract decisions before enabling the endpoint + +These need the migrated frontend (and a Postgres-backed integration test) to +settle, so they are intentionally not encoded as untested production code yet: + +- **Cross-instance ephemeral** — done. Repo ephemeral gossip is published over + the collaboration `NOTIFY` channel in a typed envelope + (`realtime.CollaborationEphemeral`) that coexists with the bare version-id + "changed" signal; the receiving instance fans it out to local peers and + suppresses the publisher's own echo. +- **Seeding** — the legacy handshake ships `SeedContent` for the client to apply; + the repo protocol has no such field. The repo endpoint must instead seed the + server-side document (authoritative) before serving, or rely on the first + writer. Which of these the frontend expects is undecided. +- **Auth token transport** — a repo client sets no cookies by default; how the + frontend presents the session/bearer credential on the WebSocket upgrade must + match `authn` middleware expectations. + +## Deliberately deferred + +- `remote-subscription-change` and `remote-heads-changed` handling (not needed by + a single-authority gateway; revisit if multi-storage subscription is wanted). +- Storage adapters: PostgreSQL remains the document authority; we do not adopt + repo storage. diff --git a/pkg/automerge/collaboration/client.go b/pkg/automerge/collaboration/client.go new file mode 100644 index 0000000000..6b98382dbc --- /dev/null +++ b/pkg/automerge/collaboration/client.go @@ -0,0 +1,263 @@ +// 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 collaboration + +import ( + "context" + "fmt" +) + +// ClientConfig configures the client side of a collaboration connection, used by +// Go agents that participate as repo peers. +type ClientConfig struct { + // ClientPeerID is the peer id the client advertises. It is the client's own + // identifier, chosen by the client. + ClientPeerID string + // PeerMetadata is the metadata the client presents. Optional. + PeerMetadata PeerMetadata + // DocumentID is the repo document id this connection syncs. + DocumentID string + // StartsEmpty reports whether the local document has no changes yet, which + // selects the first outbound sync message's type: a peer that does not have + // the document sends a request, one that already has it sends a sync. This + // mirrors the repo synchronizer's isNew rule. + StartsEmpty bool +} + +// ClientConn is the synchronous, deterministic driver for the client side of one +// collaboration connection. Like ServerConn it owns no socket and starts no +// goroutines: the caller sends the join frame it returns, then feeds inbound +// frames and sends the frames it returns. A Go agent wraps this with a real +// socket and its own document. +// +// A ClientConn is not safe for concurrent use. +type ClientConn struct { + config ClientConfig + sync SyncSession + serverPeerID string + joined bool + sentFirst bool + highestCount map[string]uint64 +} + +// NewClientConn creates a client connection driver for one document. +func NewClientConn(config ClientConfig, sync SyncSession) (*ClientConn, error) { + if config.ClientPeerID == "" { + return nil, fmt.Errorf("client connection requires a client peer id") + } + + if config.DocumentID == "" { + return nil, fmt.Errorf("client connection requires a document id") + } + + if sync == nil { + return nil, fmt.Errorf("client connection requires a sync session") + } + + return &ClientConn{ + config: config, + sync: sync, + highestCount: make(map[string]uint64), + }, nil +} + +// Start returns the join frame the client sends first. +func (c *ClientConn) Start() ([]byte, error) { + return EncodeJoinFrame(NewJoinFrame(c.config.ClientPeerID, c.config.PeerMetadata)) +} + +// ClientInbound is the result of handling one inbound server frame. +type ClientInbound struct { + // Outgoing frames to send to the server (sync or, for the first message, a + // request). + Outgoing [][]byte + // Ephemeral is a non-duplicate ephemeral message received from the server + // (gossiped from another peer), or nil. Its Data is a payload for the + // application, for example a presence message. + Ephemeral *Message + // Unavailable is true when the server reported the document unavailable. + Unavailable bool + // ServerError carries the message of a server error frame, which precedes the + // server closing the connection. + ServerError string +} + +// Receive handles one inbound server frame. On the peer reply it completes the +// handshake and emits the client's initial sync; on a sync or request it applies +// the payload and emits the resulting sync; on an ephemeral it de-duplicates and +// surfaces the payload; it reports an error or doc-unavailable frame. +func (c *ClientConn) Receive(ctx context.Context, frame []byte) (ClientInbound, error) { + kind, err := FrameKind(frame) + if err != nil { + return ClientInbound{}, err + } + + switch kind { + case FramePeer: + peer, err := DecodePeerFrame(frame) + if err != nil { + return ClientInbound{}, err + } + + if peer.SelectedProtocolVersion != ProtocolV1 { + return ClientInbound{}, fmt.Errorf("server selected unsupported protocol version %q", + peer.SelectedProtocolVersion) + } + + c.joined = true + c.serverPeerID = peer.SenderID + + outgoing, err := c.drainSync(ctx) + if err != nil { + return ClientInbound{}, err + } + + return ClientInbound{Outgoing: outgoing}, nil + case FrameError: + errorFrame, err := DecodeErrorFrame(frame) + if err != nil { + return ClientInbound{}, err + } + + return ClientInbound{ServerError: errorFrame.Message}, nil + } + + if !c.joined { + return ClientInbound{}, fmt.Errorf("received %q before the handshake completed", kind) + } + + switch MessageType(kind) { + case MessageSync, MessageRequest: + message, err := DecodeMessage(frame) + if err != nil { + return ClientInbound{}, err + } + + if err := c.sync.ReceiveMessage(ctx, message.Data); err != nil { + return ClientInbound{}, fmt.Errorf("cannot apply inbound sync message: %w", err) + } + + outgoing, err := c.drainSync(ctx) + if err != nil { + return ClientInbound{}, err + } + + return ClientInbound{Outgoing: outgoing}, nil + case MessageEphemeral: + message, err := DecodeMessage(frame) + if err != nil { + return ClientInbound{}, err + } + + if c.seenEphemeral(message) { + return ClientInbound{}, nil + } + + return ClientInbound{Ephemeral: &message}, nil + case MessageDocUnavailable: + return ClientInbound{Unavailable: true}, nil + default: + return ClientInbound{}, nil + } +} + +// SyncChanged drains sync frames after the local document changed, so local +// edits propagate to the server. +func (c *ClientConn) SyncChanged(ctx context.Context) ([][]byte, error) { + if !c.joined { + return nil, fmt.Errorf("cannot sync before the handshake completed") + } + + return c.drainSync(ctx) +} + +// Ephemeral builds an ephemeral frame carrying an application payload (such as a +// presence message) for the server to gossip. The caller supplies the session id +// chosen at startup and a per-session count that must strictly increase. +func (c *ClientConn) Ephemeral(sessionID string, count uint64, payload []byte) ([]byte, error) { + if !c.joined { + return nil, fmt.Errorf("cannot send ephemeral before the handshake completed") + } + + return EncodeMessage(Message{ + Type: MessageEphemeral, + SenderID: c.config.ClientPeerID, + TargetID: c.serverPeerID, + DocumentID: c.config.DocumentID, + SessionID: sessionID, + Count: count, + Data: payload, + }) +} + +// drainSync generates sync frames until the client is up to date. The first +// outbound message is a request when the local document started empty, matching +// the repo synchronizer; every later message is a sync. +func (c *ClientConn) drainSync(ctx context.Context) ([][]byte, error) { + var frames [][]byte + + for { + message, ok, err := c.sync.GenerateMessage(ctx) + if err != nil { + return nil, fmt.Errorf("cannot generate sync message: %w", err) + } + + if !ok { + return frames, nil + } + + messageType := MessageSync + if !c.sentFirst && c.config.StartsEmpty { + messageType = MessageRequest + } + + c.sentFirst = true + + frame, err := EncodeMessage(Message{ + Type: messageType, + SenderID: c.config.ClientPeerID, + TargetID: c.serverPeerID, + DocumentID: c.config.DocumentID, + Data: message, + }) + if err != nil { + return nil, err + } + + frames = append(frames, frame) + } +} + +func (c *ClientConn) seenEphemeral(message Message) bool { + highest, ok := c.highestCount[message.SessionID] + if ok && message.Count <= highest { + return true + } + + c.highestCount[message.SessionID] = message.Count + + return false +} + +// ServerPeerID returns the server's peer id once the handshake has completed. +func (c *ClientConn) ServerPeerID() string { + return c.serverPeerID +} diff --git a/pkg/automerge/collaboration/client_test.go b/pkg/automerge/collaboration/client_test.go new file mode 100644 index 0000000000..bd29526ee3 --- /dev/null +++ b/pkg/automerge/collaboration/client_test.go @@ -0,0 +1,253 @@ +// 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 collaboration_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/collaboration" +) + +// clientServerHarness drives a ClientConn against a ServerConn, moving frames +// between them until both are quiescent, so a test can assert convergence. +type clientServerHarness struct { + t *testing.T + ctx context.Context + client *collaboration.ClientConn + server *collaboration.ServerConn +} + +// pump exchanges frames until neither side produces more, starting from the +// client's join. It bounds the rounds so a protocol bug fails instead of hangs. +func (h *clientServerHarness) pump() { + h.t.Helper() + + join, err := h.client.Start() + require.NoError(h.t, err) + + serverOut, accepted, err := h.server.Start(h.ctx, join) + require.NoError(h.t, err) + require.True(h.t, accepted) + + toClient := serverOut + var toServer [][]byte + + for round := 0; round < 50; round++ { + var nextToServer [][]byte + + for _, frame := range toClient { + inbound, err := h.client.Receive(h.ctx, frame) + require.NoError(h.t, err) + nextToServer = append(nextToServer, inbound.Outgoing...) + } + + toClient = nil + + var nextToClient [][]byte + + for _, frame := range append(toServer, nextToServer...) { + reply, _, err := h.server.Receive(h.ctx, frame) + require.NoError(h.t, err) + nextToClient = append(nextToClient, reply...) + } + + toServer = nil + + if len(nextToServer) == 0 && len(nextToClient) == 0 { + return + } + + toClient = nextToClient + } + + h.t.Fatal("client and server did not converge") +} + +func newHarness( + t *testing.T, + ctx context.Context, + client *automerge.Document, + clientEmpty bool, + server *automerge.Document, +) *clientServerHarness { + t.Helper() + + clientSync, err := client.NewSyncState(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSync.Close(ctx) }) + + serverSync, err := server.NewSyncState(ctx) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSync.Close(ctx) }) + + clientConn, err := collaboration.NewClientConn(collaboration.ClientConfig{ + ClientPeerID: "agent", + DocumentID: "doc-1", + StartsEmpty: clientEmpty, + }, clientSync) + require.NoError(t, err) + + serverConn, err := collaboration.NewServerConn( + collaboration.ServerConfig{ServerPeerID: "server"}, + "doc-1", + serverSync, + ) + require.NoError(t, err) + + return &clientServerHarness{t: t, ctx: ctx, client: clientConn, server: serverConn} +} + +// TestClientConn_LearnsServerDocument syncs an empty Go client from a server +// that holds the document, driver to driver. +func TestClientConn_LearnsServerDocument(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + server, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + defer func() { _ = server.Close(ctx) }() + + text, err := server.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + _, err = server.Commit(ctx, "seed", commitTime()) + require.NoError(t, err) + + client, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + defer func() { _ = client.Close(ctx) }() + + newHarness(t, ctx, client, true, server).pump() + + serverHeads, err := server.Heads(ctx) + require.NoError(t, err) + clientHeads, err := client.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, serverHeads, clientHeads) + + clientText, err := client.Text(ctx, "body") + require.NoError(t, err) + value, err := clientText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "hello world", value) +} + +// TestClientConn_PushesLocalEdits syncs a client that already has content into a +// server that starts empty, so the request/sync direction is reversed. +func TestClientConn_PushesLocalEdits(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + server, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + defer func() { _ = server.Close(ctx) }() + + client, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + defer func() { _ = client.Close(ctx) }() + + text, err := client.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "from the agent")) + _, err = client.Commit(ctx, "edit", commitTime()) + require.NoError(t, err) + + newHarness(t, ctx, client, false, server).pump() + + serverText, err := server.Text(ctx, "body") + require.NoError(t, err) + value, err := serverText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "from the agent", value) +} + +// TestClientConn_FirstMessageIsRequestWhenEmpty checks the request/sync +// selection an empty client uses for its first outbound message. +func TestClientConn_FirstMessageIsRequestWhenEmpty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + client, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + defer func() { _ = client.Close(ctx) }() + + sync, err := client.NewSyncState(ctx) + require.NoError(t, err) + defer func() { _ = sync.Close(ctx) }() + + conn, err := collaboration.NewClientConn(collaboration.ClientConfig{ + ClientPeerID: "agent", DocumentID: "doc-1", StartsEmpty: true, + }, sync) + require.NoError(t, err) + + // Complete the handshake with a peer frame so the client emits its first sync. + peer, err := collaboration.EncodePeerFrame(collaboration.PeerFrame{ + Type: collaboration.FramePeer, SenderID: "server", TargetID: "agent", + SelectedProtocolVersion: collaboration.ProtocolV1, + }) + require.NoError(t, err) + + inbound, err := conn.Receive(ctx, peer) + require.NoError(t, err) + require.NotEmpty(t, inbound.Outgoing) + + message, err := collaboration.DecodeMessage(inbound.Outgoing[0]) + require.NoError(t, err) + assert.Equal(t, collaboration.MessageRequest, message.Type, + "an empty client's first message is a request") +} + +// TestClientConn_SurfacesServerError reports an error frame to the caller. +func TestClientConn_SurfacesServerError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + client, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + defer func() { _ = client.Close(ctx) }() + + sync, err := client.NewSyncState(ctx) + require.NoError(t, err) + defer func() { _ = sync.Close(ctx) }() + + conn, err := collaboration.NewClientConn(collaboration.ClientConfig{ + ClientPeerID: "agent", DocumentID: "doc-1", StartsEmpty: true, + }, sync) + require.NoError(t, err) + + errorFrame, err := collaboration.EncodeErrorFrame(collaboration.ErrorFrame{ + Type: collaboration.FrameError, SenderID: "server", TargetID: "agent", + Message: "unauthorized", + }) + require.NoError(t, err) + + inbound, err := conn.Receive(ctx, errorFrame) + require.NoError(t, err) + assert.Equal(t, "unauthorized", inbound.ServerError) +} diff --git a/pkg/automerge/collaboration/codec.go b/pkg/automerge/collaboration/codec.go new file mode 100644 index 0000000000..81d0ec7937 --- /dev/null +++ b/pkg/automerge/collaboration/codec.go @@ -0,0 +1,96 @@ +// 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 collaboration lets the Go server and Go agents speak the +// automerge-repo protocol against Probo's own pure-Go CRDT engine. This file +// defines the shared CBOR modes used for every protocol payload. +// +// The wire format is produced upstream by cbor-x. Our decoder accepts what that +// encoder emits (including its non-canonical 16-bit map-length prefixes) and our +// encoder produces deterministic CBOR that cbor-x decodes without issue; byte +// identity with cbor-x is deliberately not a goal, semantic interoperability is. +// The protocol and its ground-truth fixtures are described in PROTOCOL.md. +package collaboration + +import ( + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// Resource limits guard the decoder against hostile payloads. Presence values +// are application-defined, so the bounds are generous but finite. +const ( + maxNestedLevels = 64 + maxMapPairs = 4096 + maxArrayElements = 65536 + maxDecodedPayload = 1 << 20 // 1 MiB per ephemeral payload +) + +var ( + decMode cbor.DecMode + encMode cbor.EncMode +) + +func init() { + decoder, err := cbor.DecOptions{ + // Reject streaming payloads: every length must be known up front. + IndefLength: cbor.IndefLengthForbidden, + // A repeated map key is a malformed or hostile payload, not a merge. + DupMapKey: cbor.DupMapKeyEnforcedAPF, + // The presence and repo protocols use no CBOR tags; cbor-x emits none + // because it is configured with tagUint8Array:false, so any tag is + // unexpected and rejected. + TagsMd: cbor.TagsForbidden, + MaxNestedLevels: maxNestedLevels, + MaxMapPairs: maxMapPairs, + MaxArrayElements: maxArrayElements, + }.DecMode() + if err != nil { + panic(fmt.Sprintf("collaboration: invalid CBOR decode options: %v", err)) + } + + // Deterministic core encoding: shortest-form integers and sorted map keys, + // so our output is stable and reproducible. cbor-x decodes it regardless of + // key order. + encoder, err := cbor.CoreDetEncOptions().EncMode() + if err != nil { + panic(fmt.Sprintf("collaboration: invalid CBOR encode options: %v", err)) + } + + decMode = decoder + encMode = encoder +} + +// unmarshal decodes CBOR into value using the strict shared decode mode, after +// bounding the payload size. +func unmarshal(data []byte, value any) error { + if len(data) > maxDecodedPayload { + return fmt.Errorf("collaboration payload of %d bytes exceeds the %d byte limit", + len(data), maxDecodedPayload) + } + + return decMode.Unmarshal(data, value) +} + +// marshal encodes value as deterministic CBOR using the shared encode mode. +func marshal(value any) ([]byte, error) { + return encMode.Marshal(value) +} diff --git a/pkg/automerge/collaboration/conn.go b/pkg/automerge/collaboration/conn.go new file mode 100644 index 0000000000..d15a000d3c --- /dev/null +++ b/pkg/automerge/collaboration/conn.go @@ -0,0 +1,265 @@ +// 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 collaboration + +import ( + "context" + "fmt" +) + +// SyncSession is the subset of an Automerge sync state the connection driver +// needs. *automerge.SyncState satisfies it, so the driver never touches the +// CRDT engine directly. +type SyncSession interface { + // GenerateMessage returns the next outbound sync message for this peer, or + // ok=false when the peer is up to date. + GenerateMessage(ctx context.Context) ([]byte, bool, error) + // ReceiveMessage applies an inbound sync message from this peer. + ReceiveMessage(ctx context.Context, message []byte) error +} + +// ServerConn is the synchronous, deterministic driver for one collaboration +// connection on the server side. It owns no socket and spawns no goroutines: the +// adapter reads a frame, calls the matching method, and sends the frames +// returned. This keeps the protocol behavior unit-testable and leaves I/O, +// rooms, and authentication to the server wiring. +// +// A ServerConn is not safe for concurrent use; the adapter drives it from one +// connection goroutine. +type ServerConn struct { + session *ServerSession + sync SyncSession + documentID string + adoptDocID bool + serverPeerID string + remotePeerID string + started bool +} + +// NewServerConn creates a connection driver for a known document. documentID is +// the automerge-repo document id this connection is scoped to, and sync is the +// peer's sync state. Use this when the server already knows which document the +// connection serves and both peers agree on the id; the server announces the +// document proactively after the handshake. +// +// When the id the client will request is not known ahead of time (the common +// production case, where the frontend picks the automerge: URL), use +// NewAdoptingServerConn instead. +func NewServerConn(config ServerConfig, documentID string, sync SyncSession) (*ServerConn, error) { + if documentID == "" { + return nil, fmt.Errorf("server connection requires a document id") + } + + return newServerConn(config, documentID, false, sync) +} + +// NewAdoptingServerConn creates a connection driver that learns its document id +// from the client's first sync or request frame. Because the id is unknown until +// then, the server does not announce the document in Start; it answers the +// client once the client asks for a specific document. Every subsequent frame on +// the connection must reference the same id. +func NewAdoptingServerConn(config ServerConfig, sync SyncSession) (*ServerConn, error) { + return newServerConn(config, "", true, sync) +} + +func newServerConn(config ServerConfig, documentID string, adopt bool, sync SyncSession) (*ServerConn, error) { + session, err := NewServerSession(config) + if err != nil { + return nil, err + } + + if sync == nil { + return nil, fmt.Errorf("server connection requires a sync session") + } + + return &ServerConn{ + session: session, + sync: sync, + documentID: documentID, + adoptDocID: adopt, + serverPeerID: config.ServerPeerID, + }, nil +} + +// Start processes the client's join frame and returns the frames to send: the +// handshake reply, then, when accepted, the initial sync frames that announce +// the document. When accepted is false the single reply is an error frame and +// the socket should be closed after sending it. +func (c *ServerConn) Start(ctx context.Context, joinFrame []byte) (out [][]byte, accepted bool, err error) { + if c.started { + return nil, false, fmt.Errorf("server connection already started") + } + + handshake, err := c.session.Accept(joinFrame) + if err != nil { + return nil, false, err + } + + out = append(out, handshake.Reply) + + if !handshake.Accepted { + return out, false, nil + } + + c.started = true + c.remotePeerID = handshake.RemotePeerID + + // In adopt mode the document id is unknown until the client asks for it, so + // there is nothing to announce yet: the client drives with a request frame. + if c.adoptDocID { + return out, true, nil + } + + // Announce: drain the initial sync messages the server has for this peer. + syncFrames, err := c.drainSync(ctx) + if err != nil { + return nil, false, err + } + + return append(out, syncFrames...), true, nil +} + +// Receive handles one post-handshake frame. A sync or request frame is applied +// and answered with the resulting sync frames (reply). A non-duplicate ephemeral +// frame is returned in fanout to publish to the room, unchanged. Duplicate +// ephemerals, doc-unavailable, and unrecognised control frames yield nothing. +func (c *ServerConn) Receive(ctx context.Context, frame []byte) (reply [][]byte, fanout []byte, err error) { + if !c.started { + return nil, nil, fmt.Errorf("received a frame before the connection was started") + } + + inbound, err := c.session.Receive(frame) + if err != nil { + return nil, nil, err + } + + switch inbound.Kind { + case InboundSync: + if err := c.adoptDocumentID(inbound.Message.DocumentID); err != nil { + return nil, nil, err + } + + if err := c.sync.ReceiveMessage(ctx, inbound.Message.Data); err != nil { + return nil, nil, fmt.Errorf("cannot apply inbound sync message: %w", err) + } + + frames, err := c.drainSync(ctx) + if err != nil { + return nil, nil, err + } + + return frames, nil, nil + case InboundEphemeral: + if inbound.Duplicate { + return nil, nil, nil + } + + return nil, frame, nil + default: + // InboundDocUnavailable and InboundIgnored need no server action: the + // gateway is the document authority. + return nil, nil, nil + } +} + +// SyncChanged drains any sync messages produced because the document changed +// from another source (a peer's merge, a server-side edit). The adapter calls it +// when the room signals the document advanced. +func (c *ServerConn) SyncChanged(ctx context.Context) ([][]byte, error) { + if !c.started { + return nil, fmt.Errorf("cannot sync a connection before it is started") + } + + return c.drainSync(ctx) +} + +// adoptDocumentID binds the connection to the document id the client asked for. +// A fixed-id connection rejects a mismatching id; an adopting one records the +// first non-empty id it sees and then holds the peer to it. This guarantees one +// connection only ever serves a single document. +func (c *ServerConn) adoptDocumentID(id string) error { + if id == "" { + return nil + } + + if c.documentID == "" { + c.documentID = id + + return nil + } + + if c.documentID != id { + return fmt.Errorf( + "connection scoped to document %q received a frame for document %q", + c.documentID, id, + ) + } + + return nil +} + +// drainSync generates sync frames until the peer is up to date. The server is +// always the document authority, so every generated message is a sync frame, +// never a request. Before the document id is known (adopt mode, prior to the +// client's first request) there is nothing to send. +func (c *ServerConn) drainSync(ctx context.Context) ([][]byte, error) { + if c.documentID == "" { + return nil, nil + } + + var frames [][]byte + + for { + message, ok, err := c.sync.GenerateMessage(ctx) + if err != nil { + return nil, fmt.Errorf("cannot generate sync message: %w", err) + } + + if !ok { + return frames, nil + } + + frame, err := EncodeMessage(Message{ + Type: MessageSync, + SenderID: c.serverPeerID, + TargetID: c.remotePeerID, + DocumentID: c.documentID, + Data: message, + }) + if err != nil { + return nil, err + } + + frames = append(frames, frame) + } +} + +// RemotePeerID returns the connected client's peer id after Start. +func (c *ServerConn) RemotePeerID() string { + return c.remotePeerID +} + +// DocumentID returns the document id this connection serves. For an adopting +// connection it is empty until the client's first sync or request frame binds +// it. +func (c *ServerConn) DocumentID() string { + return c.documentID +} diff --git a/pkg/automerge/collaboration/conn_test.go b/pkg/automerge/collaboration/conn_test.go new file mode 100644 index 0000000000..c75eafdabd --- /dev/null +++ b/pkg/automerge/collaboration/conn_test.go @@ -0,0 +1,302 @@ +// 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 collaboration + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// scriptedSync is a fake SyncSession: it yields a scripted sequence of outbound +// messages and records the inbound messages applied to it. +type scriptedSync struct { + outbound [][]byte + received [][]byte +} + +func (s *scriptedSync) GenerateMessage(context.Context) ([]byte, bool, error) { + if len(s.outbound) == 0 { + return nil, false, nil + } + + next := s.outbound[0] + s.outbound = s.outbound[1:] + + return next, true, nil +} + +func (s *scriptedSync) ReceiveMessage(_ context.Context, message []byte) error { + s.received = append(s.received, message) + + return nil +} + +func newConn(t *testing.T, sync SyncSession) *ServerConn { + t.Helper() + + conn, err := NewServerConn(ServerConfig{ServerPeerID: "server"}, "doc-1", sync) + require.NoError(t, err) + + return conn +} + +func joinFixture(t *testing.T) []byte { + t.Helper() + + return decodeBase64(t, readFixture[wireFixture](t, "wire-join.json").FrameCBORBase64) +} + +// TestServerConn_StartAnnouncesInitialSync sends the peer reply then a sync frame +// for each initial sync message. +func TestServerConn_StartAnnouncesInitialSync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sync := &scriptedSync{outbound: [][]byte{{1, 1}, {2, 2}}} + conn := newConn(t, sync) + + out, accepted, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + require.True(t, accepted) + require.Len(t, out, 3) // peer reply + two sync frames + + peer, err := DecodePeerFrame(out[0]) + require.NoError(t, err) + assert.Equal(t, ProtocolV1, peer.SelectedProtocolVersion) + + for _, frame := range out[1:] { + message, err := DecodeMessage(frame) + require.NoError(t, err) + assert.Equal(t, MessageSync, message.Type) + assert.Equal(t, "server", message.SenderID) + assert.Equal(t, "peer-a", message.TargetID) + assert.Equal(t, "doc-1", message.DocumentID) + } +} + +// TestServerConn_RejectsUnsupportedVersion returns an error frame and does not +// start. +func TestServerConn_RejectsUnsupportedVersion(t *testing.T) { + t.Parallel() + + join, err := marshal(JoinFrame{ + Type: FrameJoin, + SenderID: "peer-a", + SupportedProtocolVersions: []string{"999"}, + }) + require.NoError(t, err) + + conn := newConn(t, &scriptedSync{}) + out, accepted, err := conn.Start(context.Background(), join) + require.NoError(t, err) + assert.False(t, accepted) + require.Len(t, out, 1) + + _, err = DecodeErrorFrame(out[0]) + require.NoError(t, err) +} + +// TestServerConn_AppliesInboundSyncAndReplies applies an inbound sync message +// and answers with the generated sync frames. +func TestServerConn_AppliesInboundSyncAndReplies(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sync := &scriptedSync{} + conn := newConn(t, sync) + + _, accepted, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + require.True(t, accepted) + + // The next generate call yields one reply message. + sync.outbound = [][]byte{{9, 9}} + + inboundSync, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc-1", Data: []byte{7, 7}, + }) + require.NoError(t, err) + + reply, fanout, err := conn.Receive(ctx, inboundSync) + require.NoError(t, err) + assert.Nil(t, fanout) + require.Len(t, reply, 1) + + assert.Equal(t, [][]byte{{7, 7}}, sync.received) + + message, err := DecodeMessage(reply[0]) + require.NoError(t, err) + assert.Equal(t, MessageSync, message.Type) + assert.Equal(t, []byte{9, 9}, message.Data) +} + +// TestServerConn_FansOutEphemeralOnce forwards a fresh ephemeral and drops a +// duplicate. +func TestServerConn_FansOutEphemeralOnce(t *testing.T) { + t.Parallel() + + ctx := context.Background() + conn := newConn(t, &scriptedSync{}) + + _, _, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + + payload, err := EncodePresence(PresenceMessage{Type: PresenceHeartbeat}) + require.NoError(t, err) + + ephemeral, err := EncodeMessage(Message{ + Type: MessageEphemeral, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc-1", SessionID: "s", Count: 1, Data: payload, + }) + require.NoError(t, err) + + reply, fanout, err := conn.Receive(ctx, ephemeral) + require.NoError(t, err) + assert.Nil(t, reply) + assert.Equal(t, ephemeral, fanout, "a fresh ephemeral is forwarded unchanged") + + _, fanoutAgain, err := conn.Receive(ctx, ephemeral) + require.NoError(t, err) + assert.Nil(t, fanoutAgain, "a duplicate ephemeral is dropped") +} + +// TestServerConn_SyncChangedDrains produces sync frames when the document +// advanced from another source. +func TestServerConn_SyncChangedDrains(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sync := &scriptedSync{} + conn := newConn(t, sync) + + _, _, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + + sync.outbound = [][]byte{{3, 3}} + frames, err := conn.SyncChanged(ctx) + require.NoError(t, err) + require.Len(t, frames, 1) + + message, err := DecodeMessage(frames[0]) + require.NoError(t, err) + assert.Equal(t, MessageSync, message.Type) + assert.Equal(t, []byte{3, 3}, message.Data) +} + +// TestAdoptingServerConn_LearnsDocumentIDFromClient starts without a document id +// (so it announces nothing) and adopts the id from the client's first sync +// frame, then answers for that same id. +func TestAdoptingServerConn_LearnsDocumentIDFromClient(t *testing.T) { + t.Parallel() + + ctx := context.Background() + sync := &scriptedSync{} + + conn, err := NewAdoptingServerConn(ServerConfig{ServerPeerID: "server"}, sync) + require.NoError(t, err) + + out, accepted, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + require.True(t, accepted) + require.Len(t, out, 1, "an adopting connection announces nothing until the client asks") + assert.Empty(t, conn.DocumentID()) + + sync.outbound = [][]byte{{9, 9}} + + inboundSync, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "client-chosen-doc", Data: []byte{7, 7}, + }) + require.NoError(t, err) + + reply, _, err := conn.Receive(ctx, inboundSync) + require.NoError(t, err) + require.Len(t, reply, 1) + assert.Equal(t, "client-chosen-doc", conn.DocumentID()) + + message, err := DecodeMessage(reply[0]) + require.NoError(t, err) + assert.Equal(t, "client-chosen-doc", message.DocumentID, + "the server answers for the id the client requested") +} + +// TestAdoptingServerConn_RejectsSecondDocument holds the connection to the first +// document id it adopts. +func TestAdoptingServerConn_RejectsSecondDocument(t *testing.T) { + t.Parallel() + + ctx := context.Background() + conn, err := NewAdoptingServerConn(ServerConfig{ServerPeerID: "server"}, &scriptedSync{}) + require.NoError(t, err) + + _, _, err = conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + + first, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc-a", Data: []byte{1}, + }) + require.NoError(t, err) + _, _, err = conn.Receive(ctx, first) + require.NoError(t, err) + + second, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc-b", Data: []byte{2}, + }) + require.NoError(t, err) + _, _, err = conn.Receive(ctx, second) + require.Error(t, err) +} + +// TestServerConn_RejectsForeignDocument keeps a fixed-id connection from serving +// a different document than it was constructed for. +func TestServerConn_RejectsForeignDocument(t *testing.T) { + t.Parallel() + + ctx := context.Background() + conn := newConn(t, &scriptedSync{}) + + _, _, err := conn.Start(ctx, joinFixture(t)) + require.NoError(t, err) + + foreign, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "other-doc", Data: []byte{1}, + }) + require.NoError(t, err) + + _, _, err = conn.Receive(ctx, foreign) + require.Error(t, err) +} + +// TestServerConn_RequiresStart refuses frames before the handshake. +func TestServerConn_RequiresStart(t *testing.T) { + t.Parallel() + + conn := newConn(t, &scriptedSync{}) + _, _, err := conn.Receive(context.Background(), []byte{0xa0}) + assert.Error(t, err) +} diff --git a/pkg/automerge/collaboration/documentid.go b/pkg/automerge/collaboration/documentid.go new file mode 100644 index 0000000000..59125ce929 --- /dev/null +++ b/pkg/automerge/collaboration/documentid.go @@ -0,0 +1,214 @@ +// 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 collaboration + +import ( + "crypto/sha256" + "fmt" + "math/big" + "strings" +) + +// AutomergeURLPrefix is the scheme automerge-repo puts in front of a document id +// to form a document URL, for example "automerge:34YWzjYt5gPJpq5RfXAkPfPcUj1r". +const AutomergeURLPrefix = "automerge:" + +// DocumentIDByteLength is the length of the binary document id automerge-repo +// base58check-encodes: a 16-byte (128-bit) identifier. +const DocumentIDByteLength = 16 + +// base58Alphabet is the Bitcoin base58 alphabet automerge-repo's bs58check uses. +const base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +// EncodeDocumentID encodes a 16-byte identifier as an automerge-repo document id +// using base58check (base58 of the payload followed by the first four bytes of +// its double SHA-256), matching @automerge/automerge-repo's binaryToDocumentId. +func EncodeDocumentID(id [DocumentIDByteLength]byte) string { + return base58CheckEncode(id[:]) +} + +// DecodeDocumentID decodes an automerge-repo document id back to its 16 bytes, +// rejecting a bad checksum or a payload that is not 16 bytes long. +func DecodeDocumentID(documentID string) ([DocumentIDByteLength]byte, error) { + var id [DocumentIDByteLength]byte + + payload, err := base58CheckDecode(documentID) + if err != nil { + return id, fmt.Errorf("invalid automerge document id %q: %w", documentID, err) + } + + if len(payload) != DocumentIDByteLength { + return id, fmt.Errorf( + "automerge document id %q decodes to %d bytes, want %d", + documentID, len(payload), DocumentIDByteLength, + ) + } + + copy(id[:], payload) + + return id, nil +} + +// ValidDocumentID reports whether documentID is a well-formed automerge-repo +// document id (correct base58, checksum, and length). +func ValidDocumentID(documentID string) bool { + _, err := DecodeDocumentID(documentID) + + return err == nil +} + +// DeriveDocumentID derives a stable automerge-repo document id from an arbitrary +// seed string, such as a Probo document-version GID. It hashes the seed and +// takes the first 16 bytes, so every peer that knows the seed computes the same +// id without coordination. This is what lets browser clients and Go agents join +// the same repo document, and it is required for ephemeral gossip (presence and +// cursors) to line up, since a peer drops an ephemeral frame whose document id +// it does not recognise. +func DeriveDocumentID(seed string) string { + digest := sha256.Sum256([]byte(seed)) + + var id [DocumentIDByteLength]byte + copy(id[:], digest[:DocumentIDByteLength]) + + return EncodeDocumentID(id) +} + +// AutomergeURL wraps a document id in the automerge: scheme. +func AutomergeURL(documentID string) string { + return AutomergeURLPrefix + documentID +} + +// DeriveAutomergeURL derives a stable automerge: URL from a seed string. +func DeriveAutomergeURL(seed string) string { + return AutomergeURL(DeriveDocumentID(seed)) +} + +// ParseAutomergeURL extracts and validates the document id from an automerge: +// URL, rejecting a missing scheme or a malformed id. +func ParseAutomergeURL(url string) (string, error) { + documentID, found := strings.CutPrefix(url, AutomergeURLPrefix) + if !found { + return "", fmt.Errorf("automerge url %q is missing the %q scheme", url, AutomergeURLPrefix) + } + + if _, err := DecodeDocumentID(documentID); err != nil { + return "", fmt.Errorf("automerge url %q has an invalid document id: %w", url, err) + } + + return documentID, nil +} + +// base58CheckEncode appends the 4-byte double-SHA-256 checksum and base58-encodes +// the result. +func base58CheckEncode(payload []byte) string { + checked := make([]byte, 0, len(payload)+4) + checked = append(checked, payload...) + checked = append(checked, checksum(payload)...) + + return base58Encode(checked) +} + +// base58CheckDecode base58-decodes the input and verifies its trailing checksum, +// returning the payload without it. +func base58CheckDecode(encoded string) ([]byte, error) { + decoded, err := base58Decode(encoded) + if err != nil { + return nil, err + } + + if len(decoded) < 4 { + return nil, fmt.Errorf("base58check value is too short to contain a checksum") + } + + payload := decoded[:len(decoded)-4] + want := decoded[len(decoded)-4:] + + got := checksum(payload) + if got[0] != want[0] || got[1] != want[1] || got[2] != want[2] || got[3] != want[3] { + return nil, fmt.Errorf("base58check checksum mismatch") + } + + return payload, nil +} + +// checksum is the first four bytes of the double SHA-256 of the payload. +func checksum(payload []byte) []byte { + first := sha256.Sum256(payload) + second := sha256.Sum256(first[:]) + + return second[:4] +} + +func base58Encode(input []byte) string { + value := new(big.Int).SetBytes(input) + radix := big.NewInt(58) + remainder := new(big.Int) + zero := new(big.Int) + + var reversed []byte + for value.Cmp(zero) > 0 { + value.DivMod(value, radix, remainder) + reversed = append(reversed, base58Alphabet[remainder.Int64()]) + } + + // Each leading zero byte is encoded as the alphabet's first character. + for _, b := range input { + if b != 0 { + break + } + + reversed = append(reversed, base58Alphabet[0]) + } + + for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 { + reversed[i], reversed[j] = reversed[j], reversed[i] + } + + return string(reversed) +} + +func base58Decode(encoded string) ([]byte, error) { + value := new(big.Int) + radix := big.NewInt(58) + + for _, character := range encoded { + index := strings.IndexRune(base58Alphabet, character) + if index < 0 { + return nil, fmt.Errorf("invalid base58 character %q", character) + } + + value.Mul(value, radix) + value.Add(value, big.NewInt(int64(index))) + } + + decoded := value.Bytes() + + // Restore the leading zero bytes the encoder wrote as leading '1's. + zeros := 0 + for zeros < len(encoded) && encoded[zeros] == base58Alphabet[0] { + zeros++ + } + + result := make([]byte, zeros+len(decoded)) + copy(result[zeros:], decoded) + + return result, nil +} diff --git a/pkg/automerge/collaboration/documentid_test.go b/pkg/automerge/collaboration/documentid_test.go new file mode 100644 index 0000000000..d54c363247 --- /dev/null +++ b/pkg/automerge/collaboration/documentid_test.go @@ -0,0 +1,133 @@ +// 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 collaboration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// realRepoDocumentID is a genuine @automerge/automerge-repo document id (the one +// the interop client uses). Decoding it verifies our base58check implementation +// against real upstream output rather than against itself: a wrong checksum or +// alphabet would fail here. +const realRepoDocumentID = "34YWzjYt5gPJpq5RfXAkPfPcUj1r" + +// TestDocumentID_DecodesRealRepoID proves the base58check codec matches the +// upstream format: a real repo id decodes to exactly 16 bytes and re-encodes to +// the identical string. +func TestDocumentID_DecodesRealRepoID(t *testing.T) { + t.Parallel() + + id, err := DecodeDocumentID(realRepoDocumentID) + require.NoError(t, err) + + assert.Equal(t, realRepoDocumentID, EncodeDocumentID(id), + "a real repo id must round-trip byte-identically") + assert.True(t, ValidDocumentID(realRepoDocumentID)) +} + +// TestDocumentID_RoundTrip encodes and decodes arbitrary 16-byte ids, including +// ones with leading zero bytes (which base58 encodes specially). +func TestDocumentID_RoundTrip(t *testing.T) { + t.Parallel() + + cases := map[string][DocumentIDByteLength]byte{ + "zero": {}, + "leading zeros": {0, 0, 0, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 0, 0, 0}, + "all ones": {1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, + "max": {255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}, + "mixed": {0xde, 0xad, 0xbe, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb}, + } + + for name, id := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + encoded := EncodeDocumentID(id) + assert.True(t, ValidDocumentID(encoded)) + + decoded, err := DecodeDocumentID(encoded) + require.NoError(t, err) + assert.Equal(t, id, decoded) + }) + } +} + +// TestDocumentID_RejectsCorruption rejects a bad checksum, a bad character, and +// the wrong decoded length. +func TestDocumentID_RejectsCorruption(t *testing.T) { + t.Parallel() + + // Flip the last character of a valid id to break its checksum. + corrupted := realRepoDocumentID[:len(realRepoDocumentID)-1] + if realRepoDocumentID[len(realRepoDocumentID)-1] == 'r' { + corrupted += "s" + } else { + corrupted += "r" + } + + _, err := DecodeDocumentID(corrupted) + assert.Error(t, err, "a flipped character must fail the checksum") + + _, err = DecodeDocumentID("0OIl") // characters outside the base58 alphabet + assert.Error(t, err) + + assert.False(t, ValidDocumentID("")) +} + +// TestDeriveDocumentID_StableAndValid derives ids from seeds (a version GID +// stands in) and checks they are deterministic, valid, and seed-specific. +func TestDeriveDocumentID_StableAndValid(t *testing.T) { + t.Parallel() + + const seed = "document_version_2Abc123" + + first := DeriveDocumentID(seed) + second := DeriveDocumentID(seed) + + assert.Equal(t, first, second, "derivation must be deterministic") + assert.True(t, ValidDocumentID(first)) + assert.NotEqual(t, first, DeriveDocumentID(seed+"x"), + "different seeds must derive different ids") +} + +// TestAutomergeURL_RoundTrip wraps and unwraps the automerge: scheme and +// rejects malformed URLs. +func TestAutomergeURL_RoundTrip(t *testing.T) { + t.Parallel() + + url := DeriveAutomergeURL("document_version_9") + assert.True(t, len(url) > len(AutomergeURLPrefix)) + + documentID, err := ParseAutomergeURL(url) + require.NoError(t, err) + assert.Equal(t, AutomergeURL(documentID), url) + assert.True(t, ValidDocumentID(documentID)) + + _, err = ParseAutomergeURL(realRepoDocumentID) // no scheme + assert.Error(t, err) + + _, err = ParseAutomergeURL(AutomergeURLPrefix + "not-a-valid-id!!") + assert.Error(t, err) +} diff --git a/pkg/automerge/collaboration/fuzz_test.go b/pkg/automerge/collaboration/fuzz_test.go new file mode 100644 index 0000000000..ff48d20f82 --- /dev/null +++ b/pkg/automerge/collaboration/fuzz_test.go @@ -0,0 +1,81 @@ +// 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 collaboration + +import "testing" + +// FuzzDecodePresence checks that decoding arbitrary bytes never panics and only +// ever returns a valid message or an error. +func FuzzDecodePresence(f *testing.F) { + for _, seed := range [][]byte{ + nil, + {0xa0}, + {0xff}, + []byte("not cbor"), + } { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, data []byte) { + message, err := DecodePresence(data) + if err != nil { + return + } + + // A returned message must satisfy the type invariants, and re-encoding it + // must succeed and decode back to the same type. + if err := message.validate(); err != nil { + t.Fatalf("decoded presence message is invalid: %v", err) + } + + encoded, err := EncodePresence(message) + if err != nil { + t.Fatalf("cannot re-encode a decoded presence message: %v", err) + } + + again, err := DecodePresence(encoded) + if err != nil { + t.Fatalf("cannot decode a re-encoded presence message: %v", err) + } + + if again.Type != message.Type { + t.Fatalf("re-encoded type %q != %q", again.Type, message.Type) + } + }) +} + +// FuzzDecodeMessage checks that decoding arbitrary bytes as a repo message never +// panics. +func FuzzDecodeMessage(f *testing.F) { + f.Add([]byte{0xa0}) + f.Add([]byte("garbage")) + + f.Fuzz(func(t *testing.T, data []byte) { + message, err := DecodeMessage(data) + if err != nil { + return + } + + if err := message.validate(); err != nil { + t.Fatalf("decoded repo message is invalid: %v", err) + } + }) +} diff --git a/pkg/automerge/collaboration/integration_test.go b/pkg/automerge/collaboration/integration_test.go new file mode 100644 index 0000000000..94895cbead --- /dev/null +++ b/pkg/automerge/collaboration/integration_test.go @@ -0,0 +1,156 @@ +// 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 collaboration_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/collaboration" +) + +// The production sync state must satisfy the driver's interface with no adapter. +var _ collaboration.SyncSession = (*automerge.SyncState)(nil) + +func actor(value byte) automerge.ActorID { + var actorID automerge.ActorID + actorID[0] = value + + return actorID +} + +func commitTime() time.Time { + return time.Unix(1786147200, 0).UTC() +} + +// TestServerConn_ConvergesRealDocument drives a real Automerge client sync state +// through the ServerConn loop and confirms the client reconstructs the server's +// document. This exercises the interop linchpin end to end in Go: the repo sync +// frame payloads are exactly our engine's sync messages. +func TestServerConn_ConvergesRealDocument(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Server document: the authority, holding "hello" in a text object. + server, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + defer func() { _ = server.Close(ctx) }() + + text, err := server.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello")) + _, err = server.Commit(ctx, "seed", commitTime()) + require.NoError(t, err) + + serverSync, err := server.NewSyncState(ctx) + require.NoError(t, err) + defer func() { _ = serverSync.Close(ctx) }() + + conn, err := collaboration.NewServerConn( + collaboration.ServerConfig{ServerPeerID: "server"}, + "doc-1", + serverSync, + ) + require.NoError(t, err) + + // Client document: empty, learning the document over the connection. + client, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + defer func() { _ = client.Close(ctx) }() + + clientSync, err := client.NewSyncState(ctx) + require.NoError(t, err) + defer func() { _ = clientSync.Close(ctx) }() + + join, err := collaboration.EncodeJoinFrame( + collaboration.NewJoinFrame("peer-a", collaboration.PeerMetadata{}), + ) + require.NoError(t, err) + + // Frames the client still has to process; seeded with the server's announce. + toClient, accepted, err := conn.Start(ctx, join) + require.NoError(t, err) + require.True(t, accepted) + + deliverToClient := func(frames [][]byte) { + for _, frame := range frames { + kind, err := collaboration.FrameKind(frame) + require.NoError(t, err) + + if kind == collaboration.FramePeer { + continue + } + + message, err := collaboration.DecodeMessage(frame) + require.NoError(t, err) + require.Equal(t, collaboration.MessageSync, message.Type) + require.NoError(t, clientSync.ReceiveMessage(ctx, message.Data)) + } + } + + // Pump until neither side has anything more to send. A generous bound stops a + // protocol bug from hanging the test. + for round := 0; round < 20; round++ { + deliverToClient(toClient) + toClient = nil + + message, ok, err := clientSync.GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + break + } + + frame, err := collaboration.EncodeMessage(collaboration.Message{ + Type: collaboration.MessageSync, + SenderID: "peer-a", + TargetID: "server", + DocumentID: "doc-1", + Data: message, + }) + require.NoError(t, err) + + reply, fanout, err := conn.Receive(ctx, frame) + require.NoError(t, err) + assert.Nil(t, fanout) + + toClient = reply + } + + serverHeads, err := server.Heads(ctx) + require.NoError(t, err) + + clientHeads, err := client.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, serverHeads, clientHeads, "client must converge to the server frontier") + + clientText, err := client.Text(ctx, "body") + require.NoError(t, err) + + value, err := clientText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "hello", value) +} diff --git a/pkg/automerge/collaboration/interop_client_test.go b/pkg/automerge/collaboration/interop_client_test.go new file mode 100644 index 0000000000..91f7f1f7b7 --- /dev/null +++ b/pkg/automerge/collaboration/interop_client_test.go @@ -0,0 +1,182 @@ +// 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 collaboration_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/collaboration" +) + +// The document id both sides agree on: the real repo client requests this URL +// and the gateway serves its seeded document under the same id. +const ( + interopDocumentID = "34YWzjYt5gPJpq5RfXAkPfPcUj1r" + interopDocumentURL = "automerge:" + interopDocumentID +) + +// TestInterop_RealRepoClientLoadsGoDocument stands up a Go WebSocket server +// backed by ServerConn and a seeded document, then runs a real +// @automerge/automerge-repo client against it and asserts the client +// materializes the server's document. This validates the whole gateway stack +// (handshake, framing, sync loop) against the actual JavaScript client rather +// than against our reading of its source. +// +// It is gated on AUTOMERGE_REPO_INTEROP_CLIENT (the path to the Node client +// script) so the default test run does not require Node; the +// test-automerge-repo-interop make target sets it. +func TestInterop_RealRepoClientLoadsGoDocument(t *testing.T) { + script := os.Getenv("AUTOMERGE_REPO_INTEROP_CLIENT") + if script == "" { + t.Skip("AUTOMERGE_REPO_INTEROP_CLIENT is not set") + } + + ctx := context.Background() + + server, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + defer func() { _ = server.Close(ctx) }() + + text, err := server.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + _, err = server.Commit(ctx, "seed", commitTime()) + require.NoError(t, err) + + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serveGateway(t, w, r, server) + })) + defer httpServer.Close() + + wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + + runCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + command := exec.CommandContext(runCtx, "node", script, wsURL, interopDocumentURL) + + output, err := command.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + t.Fatalf("interop client failed: %v\nstderr:\n%s", err, exitErr.Stderr) + } + + t.Fatalf("cannot run interop client: %v", err) + } + + document := string(output) + assert.Contains(t, document, "hello world", + "the real repo client must materialize the server's document; got %s", document) +} + +// serveGateway is a minimal repo gateway for the interop test: it accepts a +// binary WebSocket, runs the ServerConn handshake and sync loop for the seeded +// document, and echoes each peer's own ephemerals (there is a single client, so +// fan-out is a no-op). +func serveGateway(t *testing.T, w http.ResponseWriter, r *http.Request, document *automerge.Document) { + t.Helper() + + connection, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // test-only: the Node client sends no Origin + }) + if err != nil { + return + } + + defer func() { _ = connection.Close(websocket.StatusNormalClosure, "") }() + + connection.SetReadLimit(1 << 20) + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + syncState, err := document.NewSyncState(ctx) + if err != nil { + return + } + + defer func() { _ = syncState.Close(context.Background()) }() + + conn, err := collaboration.NewServerConn( + collaboration.ServerConfig{ServerPeerID: "probo-gateway"}, + interopDocumentID, + syncState, + ) + if err != nil { + return + } + + write := func(frames [][]byte) bool { + for _, frame := range frames { + if err := connection.Write(ctx, websocket.MessageBinary, frame); err != nil { + return false + } + } + + return true + } + + // First frame is the client's join. + kind, join, err := connection.Read(ctx) + if err != nil || kind != websocket.MessageBinary { + return + } + + out, accepted, err := conn.Start(ctx, join) + if err != nil { + return + } + + if !write(out) || !accepted { + return + } + + for { + kind, frame, err := connection.Read(ctx) + if err != nil { + return + } + + if kind != websocket.MessageBinary { + continue + } + + reply, _, err := conn.Receive(ctx, frame) + if err != nil { + return + } + + if !write(reply) { + return + } + } +} diff --git a/pkg/automerge/collaboration/message.go b/pkg/automerge/collaboration/message.go new file mode 100644 index 0000000000..e78d1b91f8 --- /dev/null +++ b/pkg/automerge/collaboration/message.go @@ -0,0 +1,145 @@ +// 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 collaboration + +import "fmt" + +// MessageType is the discriminator shared by every repo message. +type MessageType string + +const ( + // MessageSync carries an Automerge sync message for a document. + MessageSync MessageType = "sync" + // MessageRequest is the initial sync that also asks whether the peer has the + // document at all. + MessageRequest MessageType = "request" + // MessageEphemeral carries a gossiped, non-persisted payload (such as + // presence) for a document. + MessageEphemeral MessageType = "ephemeral" + // MessageDocUnavailable reports that neither the peer nor its peers hold the + // document. + MessageDocUnavailable MessageType = "doc-unavailable" +) + +// Message is one document-scoped repo message. It is the layer between the +// WebSocket adapter framing (added in the transport phase) and the payload: for +// sync and request Data is an Automerge sync message our engine owns, and for +// ephemeral Data is a CBOR presence payload. +// +// Sync bytes are intentionally left opaque here so this package never +// re-implements the CRDT wire format. +type Message struct { + Type MessageType `cbor:"type"` + SenderID string `cbor:"senderId"` + TargetID string `cbor:"targetId"` + DocumentID string `cbor:"documentId"` + + // Data is the Automerge sync message for sync and request messages, and the + // CBOR presence payload for ephemeral messages. + Data []byte `cbor:"data,omitempty"` + + // SessionID and Count identify an ephemeral message for gossip + // de-duplication and are unset for other types. + SessionID string `cbor:"sessionId,omitempty"` + Count uint64 `cbor:"count,omitempty"` +} + +// validate checks that a message carries exactly the fields its type requires, +// which guards both directions of the codec. +func (m Message) validate() error { + if m.SenderID == "" { + return fmt.Errorf("repo message is missing a sender id") + } + + switch m.Type { + case MessageSync, MessageRequest: + if m.DocumentID == "" { + return fmt.Errorf("%s message is missing a document id", m.Type) + } + + if len(m.Data) == 0 { + return fmt.Errorf("%s message is missing sync data", m.Type) + } + case MessageEphemeral: + if m.DocumentID == "" { + return fmt.Errorf("ephemeral message is missing a document id") + } + + if m.SessionID == "" { + return fmt.Errorf("ephemeral message is missing a session id") + } + + if len(m.Data) == 0 { + return fmt.Errorf("ephemeral message is missing a payload") + } + case MessageDocUnavailable: + if m.DocumentID == "" { + return fmt.Errorf("doc-unavailable message is missing a document id") + } + default: + return fmt.Errorf("unknown repo message type %q", m.Type) + } + + return nil +} + +// EncodeMessage encodes a repo message to CBOR. This is the message object the +// WebSocket adapter frames; the adapter's own framing and handshake are added +// in the transport phase. +func EncodeMessage(message Message) ([]byte, error) { + if err := message.validate(); err != nil { + return nil, err + } + + data, err := marshal(message) + if err != nil { + return nil, fmt.Errorf("cannot encode repo message: %w", err) + } + + return data, nil +} + +// DecodeMessage decodes a CBOR repo message and validates it. +func DecodeMessage(data []byte) (Message, error) { + var message Message + if err := unmarshal(data, &message); err != nil { + return Message{}, fmt.Errorf("cannot decode repo message: %w", err) + } + + if err := message.validate(); err != nil { + return Message{}, err + } + + return message, nil +} + +// DedupeKey identifies an ephemeral message for gossip de-duplication: a +// receiver discards a (session, count) pair it has already seen, which breaks +// forwarding loops. It is only meaningful for ephemeral messages. +type DedupeKey struct { + SessionID string + Count uint64 +} + +// DedupeKey returns the de-duplication key for an ephemeral message. +func (m Message) DedupeKey() DedupeKey { + return DedupeKey{SessionID: m.SessionID, Count: m.Count} +} diff --git a/pkg/automerge/collaboration/message_test.go b/pkg/automerge/collaboration/message_test.go new file mode 100644 index 0000000000..e68f9e1123 --- /dev/null +++ b/pkg/automerge/collaboration/message_test.go @@ -0,0 +1,178 @@ +// 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 collaboration + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type ephemeralFixture struct { + Description string `json:"description"` + Message struct { + Type string `json:"type"` + SenderID string `json:"senderId"` + TargetID string `json:"targetId"` + DocumentID string `json:"documentId"` + SessionID string `json:"sessionId"` + Count uint64 `json:"count"` + Data string `json:"data"` + } `json:"message"` + PayloadCBORBase64 string `json:"payloadCborBase64"` +} + +// TestEphemeralFixtureCarriesPresence confirms the ephemeral message fields the +// JavaScript client sends decode into our Message, and that the payload it +// carries is exactly a presence payload our presence codec reads. +func TestEphemeralFixtureCarriesPresence(t *testing.T) { + t.Parallel() + + for _, name := range []string{ + "ephemeral-update.json", + "ephemeral-snapshot.json", + "ephemeral-heartbeat.json", + "ephemeral-goodbye.json", + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + fixture := readFixture[ephemeralFixture](t, name) + + message := Message{ + Type: MessageType(fixture.Message.Type), + SenderID: fixture.Message.SenderID, + TargetID: fixture.Message.TargetID, + DocumentID: fixture.Message.DocumentID, + SessionID: fixture.Message.SessionID, + Count: fixture.Message.Count, + Data: decodeBase64(t, fixture.Message.Data), + } + + require.NoError(t, message.validate()) + assert.Equal(t, MessageEphemeral, message.Type) + assert.Equal(t, DedupeKey{SessionID: "session-a", Count: 1}, message.DedupeKey()) + + // The ephemeral payload is a presence message the presence codec reads. + presence, err := DecodePresence(message.Data) + require.NoError(t, err) + assert.Contains(t, + []PresenceType{PresenceUpdate, PresenceSnapshot, PresenceHeartbeat, PresenceGoodbye}, + presence.Type, + ) + }) + } +} + +// TestMessage_RoundTrip encodes and decodes each document-scoped message type. +func TestMessage_RoundTrip(t *testing.T) { + t.Parallel() + + for _, message := range []Message{ + {Type: MessageSync, SenderID: "a", TargetID: "b", DocumentID: "doc", Data: []byte{1, 2, 3}}, + {Type: MessageRequest, SenderID: "a", TargetID: "b", DocumentID: "doc", Data: []byte{4, 5}}, + {Type: MessageEphemeral, SenderID: "a", TargetID: "b", DocumentID: "doc", SessionID: "s", Count: 7, Data: []byte{6}}, + {Type: MessageDocUnavailable, SenderID: "a", TargetID: "b", DocumentID: "doc"}, + } { + t.Run(string(message.Type), func(t *testing.T) { + t.Parallel() + + encoded, err := EncodeMessage(message) + require.NoError(t, err) + + decoded, err := DecodeMessage(encoded) + require.NoError(t, err) + assert.Equal(t, message.Type, decoded.Type) + assert.Equal(t, message.DocumentID, decoded.DocumentID) + assert.Equal(t, message.Data, decoded.Data) + assert.Equal(t, message.SessionID, decoded.SessionID) + assert.Equal(t, message.Count, decoded.Count) + }) + } +} + +// TestMessage_Validation rejects messages missing type-required fields. +func TestMessage_Validation(t *testing.T) { + t.Parallel() + + cases := []Message{ + {Type: MessageSync, SenderID: "a", TargetID: "b", DocumentID: "doc"}, // no data + {Type: MessageSync, SenderID: "a", TargetID: "b", Data: []byte{1}}, // no doc + {Type: MessageEphemeral, SenderID: "a", DocumentID: "d", Data: []byte{1}}, // no session + {Type: MessageType("bogus"), SenderID: "a"}, + {Type: MessageSync}, + } + + for _, message := range cases { + _, err := EncodeMessage(message) + assert.Error(t, err) + } +} + +// TestDecodeMessage_RejectsDuplicateKeys confirms the strict decoder refuses a +// duplicate map key rather than silently taking one. +func TestDecodeMessage_RejectsDuplicateKeys(t *testing.T) { + t.Parallel() + + // { "type":"doc-unavailable", "type":"sync", "senderId":"a", ... } built by + // hand as CBOR with a duplicated key. + duplicate := buildDuplicateKeyCBOR(t) + + _, err := DecodeMessage(duplicate) + assert.Error(t, err) +} + +func buildDuplicateKeyCBOR(t *testing.T) []byte { + t.Helper() + + // Encode two separate maps and splice a duplicate "type" key by hand would be + // fragile; instead assert the decoder mode rejects duplicates via a crafted + // map[string] with the same key twice is impossible in Go, so encode a raw + // CBOR map literal. + // + // CBOR: map(4) { "type":"sync", "type":"sync", "senderId":"a", "documentId":"d" } + // 0xa4 (map,4) + // "type"(0x64 74797065) "sync"(0x64 73796e63) + // "type"(0x64 74797065) "sync"(0x64 73796e63) + // "senderId"(0x68 ...) "a"(0x61 61) + // "documentId"(0x6a ...) "d"(0x61 64) + return []byte{ + 0xa4, + 0x64, 't', 'y', 'p', 'e', 0x64, 's', 'y', 'n', 'c', + 0x64, 't', 'y', 'p', 'e', 0x64, 's', 'y', 'n', 'c', + 0x68, 's', 'e', 'n', 'd', 'e', 'r', 'I', 'd', 0x61, 'a', + 0x6a, 'd', 'o', 'c', 'u', 'm', 'e', 'n', 't', 'I', 'd', 0x61, 'd', + } +} + +// Ensure the fixture files are valid JSON we can parse (guards regeneration). +func TestFixturesAreParseable(t *testing.T) { + t.Parallel() + + for _, name := range []string{"presence-roundtrip.json"} { + var raw map[string]json.RawMessage + fixture := readFixture[map[string]json.RawMessage](t, name) + raw = fixture + assert.Contains(t, raw, "cborBase64") + } +} diff --git a/pkg/automerge/collaboration/presence.go b/pkg/automerge/collaboration/presence.go new file mode 100644 index 0000000000..9aca25981c --- /dev/null +++ b/pkg/automerge/collaboration/presence.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. + +package collaboration + +import ( + "fmt" + "time" + + "github.com/fxamacker/cbor/v2" +) + +// PresenceMarker is the single envelope key automerge-repo's Presence wraps +// every message in before it is CBOR-encoded into an ephemeral payload. +const PresenceMarker = "__presence" + +// Presence heartbeat and expiry defaults, matching the upstream constants. +const ( + DefaultHeartbeatInterval = 15 * time.Second + DefaultPeerTTL = 3 * DefaultHeartbeatInterval +) + +// PresenceType is the discriminator of a presence message. +type PresenceType string + +const ( + // PresenceUpdate carries one channel's new value. + PresenceUpdate PresenceType = "update" + // PresenceSnapshot carries the full multi-channel state, sent on start and + // to newly seen peers. + PresenceSnapshot PresenceType = "snapshot" + // PresenceHeartbeat signals liveness when nothing has changed. + PresenceHeartbeat PresenceType = "heartbeat" + // PresenceGoodbye tells peers to forget the sender immediately. + PresenceGoodbye PresenceType = "goodbye" +) + +// PresenceMessage is one decoded presence event. Value (for an update) and +// State (for a snapshot) are application-defined and kept as raw CBOR so a +// caller decodes them into its own type; use Set/Get helpers or the cbor +// package directly. They are nil for heartbeat and goodbye. +type PresenceMessage struct { + Type PresenceType + Channel string + Value cbor.RawMessage + State cbor.RawMessage +} + +// presenceEnvelope is the CBOR shape on the wire: a single-key map whose key is +// the presence marker. +type presenceEnvelope struct { + Presence *presenceBody `cbor:"__presence"` +} + +type presenceBody struct { + Type string `cbor:"type"` + Channel string `cbor:"channel,omitempty"` + Value cbor.RawMessage `cbor:"value,omitempty"` + State cbor.RawMessage `cbor:"state,omitempty"` +} + +// EncodePresence encodes a presence message into the CBOR bytes carried as an +// ephemeral message's data. It validates that the fields present match the type. +func EncodePresence(message PresenceMessage) ([]byte, error) { + if err := message.validate(); err != nil { + return nil, err + } + + body := &presenceBody{ + Type: string(message.Type), + Channel: message.Channel, + Value: message.Value, + State: message.State, + } + + data, err := marshal(presenceEnvelope{Presence: body}) + if err != nil { + return nil, fmt.Errorf("cannot encode presence message: %w", err) + } + + return data, nil +} + +// DecodePresence decodes the CBOR bytes of an ephemeral message's data into a +// presence message, rejecting a payload that is not a presence envelope or whose +// fields do not match its type. +func DecodePresence(data []byte) (PresenceMessage, error) { + var envelope presenceEnvelope + if err := unmarshal(data, &envelope); err != nil { + return PresenceMessage{}, fmt.Errorf("cannot decode presence envelope: %w", err) + } + + if envelope.Presence == nil { + return PresenceMessage{}, fmt.Errorf("payload is missing the %q presence marker", PresenceMarker) + } + + message := PresenceMessage{ + Type: PresenceType(envelope.Presence.Type), + Channel: envelope.Presence.Channel, + Value: envelope.Presence.Value, + State: envelope.Presence.State, + } + + if err := message.validate(); err != nil { + return PresenceMessage{}, err + } + + return message, nil +} + +// validate enforces that only the fields meaningful for a type are set, so a +// malformed cross-type payload (an update with no channel, a heartbeat carrying +// state) is rejected on both encode and decode. +func (m PresenceMessage) validate() error { + switch m.Type { + case PresenceUpdate: + if m.Channel == "" { + return fmt.Errorf("presence update requires a channel") + } + + if m.State != nil { + return fmt.Errorf("presence update must not carry snapshot state") + } + case PresenceSnapshot: + if m.Channel != "" || m.Value != nil { + return fmt.Errorf("presence snapshot must not carry a channel or value") + } + case PresenceHeartbeat, PresenceGoodbye: + if m.Channel != "" || m.Value != nil || m.State != nil { + return fmt.Errorf("presence %s must not carry a channel, value, or state", m.Type) + } + default: + return fmt.Errorf("unknown presence type %q", m.Type) + } + + return nil +} + +// MarshalPresenceValue encodes an application value into the raw CBOR carried by +// an update's Value or a snapshot's State, using the shared deterministic mode. +func MarshalPresenceValue(value any) (cbor.RawMessage, error) { + data, err := marshal(value) + if err != nil { + return nil, fmt.Errorf("cannot encode presence value: %w", err) + } + + return data, nil +} + +// UnmarshalValue decodes an update's Value into the given destination. +func (m PresenceMessage) UnmarshalValue(destination any) error { + if m.Value == nil { + return fmt.Errorf("presence message has no value") + } + + return unmarshal(m.Value, destination) +} + +// UnmarshalState decodes a snapshot's State into the given destination. +func (m PresenceMessage) UnmarshalState(destination any) error { + if m.State == nil { + return fmt.Errorf("presence message has no state") + } + + return unmarshal(m.State, destination) +} diff --git a/pkg/automerge/collaboration/presence_test.go b/pkg/automerge/collaboration/presence_test.go new file mode 100644 index 0000000000..549d7b7fa0 --- /dev/null +++ b/pkg/automerge/collaboration/presence_test.go @@ -0,0 +1,200 @@ +// 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 collaboration + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type presenceFixture struct { + Description string `json:"description"` + Marker string `json:"marker"` + Envelope json.RawMessage `json:"envelope"` + CBORBase64 string `json:"cborBase64"` +} + +func readFixture[T any](t *testing.T, name string) T { + t.Helper() + + data, err := os.ReadFile(filepath.Join("testdata", name)) + require.NoError(t, err) + + var fixture T + require.NoError(t, json.Unmarshal(data, &fixture)) + + return fixture +} + +func decodeBase64(t *testing.T, value string) []byte { + t.Helper() + + data, err := base64.StdEncoding.DecodeString(value) + require.NoError(t, err) + + return data +} + +// TestDecodePresence_MatchesJavaScriptFixtures decodes the exact CBOR the +// JavaScript client produces for every presence message type. +func TestDecodePresence_MatchesJavaScriptFixtures(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + fixture string + expect func(t *testing.T, message PresenceMessage) + }{ + { + fixture: "presence-update.json", + expect: func(t *testing.T, message PresenceMessage) { + assert.Equal(t, PresenceUpdate, message.Type) + assert.Equal(t, "cursor", message.Channel) + + var value map[string]string + require.NoError(t, message.UnmarshalValue(&value)) + assert.Equal(t, map[string]string{"anchor": "a", "head": "b"}, value) + }, + }, + { + fixture: "presence-snapshot.json", + expect: func(t *testing.T, message PresenceMessage) { + assert.Equal(t, PresenceSnapshot, message.Type) + + var state map[string]map[string]string + require.NoError(t, message.UnmarshalState(&state)) + assert.Equal(t, map[string]map[string]string{ + "cursor": {"anchor": "a", "head": "b"}, + }, state) + }, + }, + { + fixture: "presence-heartbeat.json", + expect: func(t *testing.T, message PresenceMessage) { + assert.Equal(t, PresenceHeartbeat, message.Type) + assert.Nil(t, message.Value) + assert.Nil(t, message.State) + }, + }, + { + fixture: "presence-goodbye.json", + expect: func(t *testing.T, message PresenceMessage) { + assert.Equal(t, PresenceGoodbye, message.Type) + }, + }, + } { + t.Run(testCase.fixture, func(t *testing.T) { + t.Parallel() + + fixture := readFixture[presenceFixture](t, testCase.fixture) + assert.Equal(t, PresenceMarker, fixture.Marker) + + message, err := DecodePresence(decodeBase64(t, fixture.CBORBase64)) + require.NoError(t, err) + + testCase.expect(t, message) + }) + } +} + +// TestPresence_RoundTripsThroughJavaScriptBytes proves our re-encoding of a +// decoded JavaScript payload decodes back to the same message, so a Go peer and +// a JS peer agree on meaning even though the byte encodings need not be identical. +func TestPresence_RoundTripsThroughJavaScriptBytes(t *testing.T) { + t.Parallel() + + fixture := readFixture[presenceFixture](t, "presence-update.json") + original := decodeBase64(t, fixture.CBORBase64) + + message, err := DecodePresence(original) + require.NoError(t, err) + + reEncoded, err := EncodePresence(message) + require.NoError(t, err) + + roundTripped, err := DecodePresence(reEncoded) + require.NoError(t, err) + assert.Equal(t, message.Type, roundTripped.Type) + assert.Equal(t, message.Channel, roundTripped.Channel) + + var first, second map[string]string + require.NoError(t, message.UnmarshalValue(&first)) + require.NoError(t, roundTripped.UnmarshalValue(&second)) + assert.Equal(t, first, second) +} + +// TestEncodePresence_BuildsUpdate constructs an update from scratch and confirms +// it decodes to the same value, the path a Go agent takes when broadcasting. +func TestEncodePresence_BuildsUpdate(t *testing.T) { + t.Parallel() + + value, err := MarshalPresenceValue(map[string]string{"anchor": "x", "head": "y"}) + require.NoError(t, err) + + data, err := EncodePresence(PresenceMessage{ + Type: PresenceUpdate, + Channel: "cursor", + Value: value, + }) + require.NoError(t, err) + + message, err := DecodePresence(data) + require.NoError(t, err) + assert.Equal(t, PresenceUpdate, message.Type) + + var decoded map[string]string + require.NoError(t, message.UnmarshalValue(&decoded)) + assert.Equal(t, map[string]string{"anchor": "x", "head": "y"}, decoded) +} + +// TestEncodePresence_RejectsCrossTypeFields guards the type/field invariants. +func TestEncodePresence_RejectsCrossTypeFields(t *testing.T) { + t.Parallel() + + _, err := EncodePresence(PresenceMessage{Type: PresenceUpdate}) + assert.Error(t, err, "update without a channel must be rejected") + + state, err := MarshalPresenceValue(map[string]int{"n": 1}) + require.NoError(t, err) + + _, err = EncodePresence(PresenceMessage{Type: PresenceHeartbeat, State: state}) + assert.Error(t, err, "heartbeat carrying state must be rejected") + + _, err = EncodePresence(PresenceMessage{Type: PresenceType("bogus")}) + assert.Error(t, err, "unknown type must be rejected") +} + +// TestDecodePresence_RejectsNonPresencePayload ensures a well-formed CBOR map +// that is not a presence envelope is refused rather than silently accepted. +func TestDecodePresence_RejectsNonPresencePayload(t *testing.T) { + t.Parallel() + + other, err := marshal(map[string]string{"hello": "world"}) + require.NoError(t, err) + + _, err = DecodePresence(other) + assert.Error(t, err) +} diff --git a/pkg/automerge/collaboration/selection.go b/pkg/automerge/collaboration/selection.go new file mode 100644 index 0000000000..166c14a0ed --- /dev/null +++ b/pkg/automerge/collaboration/selection.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. + +package collaboration + +import ( + "bytes" + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +// TextSelectionChannel is the conventional presence channel a caret or selection +// update is published on, so peers know where to look for remote collaborators. +// It is only a convention; any channel string is valid. +const TextSelectionChannel = "selection" + +// TextSelectionValue is a collaborator's caret or selection carried inside a +// presence update, expressed with stable Automerge text cursors rather than +// integer offsets. +// +// An integer offset is invalidated by any concurrent edit before it: insert one +// character at the document start and every downstream offset is off by one, so +// a remote caret drawn from a stale offset drifts onto the wrong character. A +// cursor is an opaque, stable address into the sequence; resolving it against +// the current (or any later) document yields the position of the very character +// it was created for, which is what keeps a remote caret anchored while other +// people type. The bytes are exactly the output of pkg/automerge Text.Cursor and +// are resolved with Text.CursorPosition. This package only transports them, so +// it stays independent of the CRDT engine. +type TextSelectionValue struct { + // Field is the Automerge map key of the text object the selection addresses + // (for example "body"), so a consumer resolves the cursors against the right + // object when a document holds more than one text. + Field string `cbor:"field"` + // Anchor is the stable cursor for the fixed end of the selection (where the + // selection started). + Anchor []byte `cbor:"anchor"` + // Head is the stable cursor for the moving end (the caret). When Head equals + // Anchor the selection is a collapsed caret. + Head []byte `cbor:"head"` +} + +func (v TextSelectionValue) validate() error { + if v.Field == "" { + return fmt.Errorf("text selection requires a field") + } + + if len(v.Anchor) == 0 { + return fmt.Errorf("text selection requires an anchor cursor") + } + + if len(v.Head) == 0 { + return fmt.Errorf("text selection requires a head cursor") + } + + return nil +} + +// Collapsed reports whether the selection is a single caret, that is its anchor +// and head address the same position. +func (v TextSelectionValue) Collapsed() bool { + return bytes.Equal(v.Anchor, v.Head) +} + +// Encode marshals the selection into the raw CBOR carried by a presence update's +// Value, validating it first. +func (v TextSelectionValue) Encode() (cbor.RawMessage, error) { + if err := v.validate(); err != nil { + return nil, err + } + + return MarshalPresenceValue(v) +} + +// DecodeTextSelectionValue decodes a presence update's Value into a selection, +// rejecting one missing a field or either cursor. +func DecodeTextSelectionValue(raw cbor.RawMessage) (TextSelectionValue, error) { + var value TextSelectionValue + if err := unmarshal(raw, &value); err != nil { + return TextSelectionValue{}, fmt.Errorf("cannot decode text selection: %w", err) + } + + if err := value.validate(); err != nil { + return TextSelectionValue{}, err + } + + return value, nil +} + +// NewTextSelectionPresence builds a presence update message that publishes a +// caret or selection on the given channel. An empty channel defaults to +// TextSelectionChannel. +func NewTextSelectionPresence(channel string, value TextSelectionValue) (PresenceMessage, error) { + if channel == "" { + channel = TextSelectionChannel + } + + raw, err := value.Encode() + if err != nil { + return PresenceMessage{}, err + } + + return PresenceMessage{Type: PresenceUpdate, Channel: channel, Value: raw}, nil +} + +// TextSelection decodes this presence message's value as a text selection. It is +// a convenience over UnmarshalValue that also validates the selection shape. +func (m PresenceMessage) TextSelection() (TextSelectionValue, error) { + if m.Type != PresenceUpdate { + return TextSelectionValue{}, fmt.Errorf("presence %s does not carry a selection update", m.Type) + } + + if m.Value == nil { + return TextSelectionValue{}, fmt.Errorf("presence update has no value") + } + + return DecodeTextSelectionValue(m.Value) +} diff --git a/pkg/automerge/collaboration/selection_test.go b/pkg/automerge/collaboration/selection_test.go new file mode 100644 index 0000000000..78c9651e01 --- /dev/null +++ b/pkg/automerge/collaboration/selection_test.go @@ -0,0 +1,138 @@ +// 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 collaboration_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/collaboration" +) + +// TestTextSelectionValue_SurvivesConcurrentInsert is the reason presence carries +// cursors rather than offsets: it round-trips a caret through the presence +// envelope, then edits the text in front of the caret and shows the same cursor +// bytes still resolve to the same character. An integer offset would be stale. +func TestTextSelectionValue_SurvivesConcurrentInsert(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + document, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + defer func() { _ = document.Close(ctx) }() + + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello world")) + _, err = document.Commit(ctx, "seed", commitTime()) + require.NoError(t, err) + + // Put the caret on the "w" of "world". + const caretIndex = 6 + + cursor, err := text.Cursor(ctx, caretIndex) + require.NoError(t, err) + + selection := collaboration.TextSelectionValue{ + Field: "body", + Anchor: cursor, + Head: cursor, + } + assert.True(t, selection.Collapsed()) + + message, err := collaboration.NewTextSelectionPresence("", selection) + require.NoError(t, err) + assert.Equal(t, collaboration.TextSelectionChannel, message.Channel) + + payload, err := collaboration.EncodePresence(message) + require.NoError(t, err) + + // The remote side decodes the presence frame back into a selection. + decodedMessage, err := collaboration.DecodePresence(payload) + require.NoError(t, err) + + decoded, err := decodedMessage.TextSelection() + require.NoError(t, err) + assert.Equal(t, "body", decoded.Field) + assert.True(t, decoded.Collapsed()) + + positionBefore, err := text.CursorPosition(ctx, automerge.Cursor(decoded.Head)) + require.NoError(t, err) + assert.Equal(t, uint32(caretIndex), positionBefore) + + // Someone types three characters at the very start of the document. + require.NoError(t, text.Splice(ctx, 0, 0, "XX ")) + _, err = document.Commit(ctx, "insert", commitTime()) + require.NoError(t, err) + + // The very same cursor bytes now resolve three positions later: the caret + // stayed anchored to "w". A stored integer offset of 6 would now point at + // the wrong character. + positionAfter, err := text.CursorPosition(ctx, automerge.Cursor(decoded.Head)) + require.NoError(t, err) + assert.Equal(t, positionBefore+3, positionAfter) + + // The text is ASCII, so the UTF-16 position equals the byte index. + value, err := text.String(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(value), int(positionAfter)+1) + assert.Equal(t, byte('w'), value[positionAfter]) +} + +// TestTextSelectionValue_Validation rejects selections missing a field or a +// cursor on both encode and decode. +func TestTextSelectionValue_Validation(t *testing.T) { + t.Parallel() + + cursor := []byte{1, 2, 3} + + cases := map[string]collaboration.TextSelectionValue{ + "missing field": {Anchor: cursor, Head: cursor}, + "missing anchor": {Field: "body", Head: cursor}, + "missing head": {Field: "body", Anchor: cursor}, + } + + for name, selection := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := selection.Encode() + assert.Error(t, err) + + _, err = collaboration.NewTextSelectionPresence("", selection) + assert.Error(t, err) + }) + } +} + +// TestPresenceMessage_TextSelectionRejectsNonUpdate refuses to read a selection +// from a heartbeat or goodbye. +func TestPresenceMessage_TextSelectionRejectsNonUpdate(t *testing.T) { + t.Parallel() + + message := collaboration.PresenceMessage{Type: collaboration.PresenceHeartbeat} + _, err := message.TextSelection() + assert.Error(t, err) +} diff --git a/pkg/automerge/collaboration/session.go b/pkg/automerge/collaboration/session.go new file mode 100644 index 0000000000..f45c1f4734 --- /dev/null +++ b/pkg/automerge/collaboration/session.go @@ -0,0 +1,214 @@ +// 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 collaboration + +import "fmt" + +// ServerConfig configures one server side of a collaboration connection. +type ServerConfig struct { + // ServerPeerID is the peer id the server advertises in its peer reply. It is + // the server's own identifier and is unrelated to any client identity. + ServerPeerID string + // PeerMetadata is the metadata the server presents. Optional. + PeerMetadata PeerMetadata +} + +// ServerSession is the transport-agnostic state machine for one collaboration +// connection as seen by the server. It negotiates the handshake, routes inbound +// frames, and de-duplicates gossiped ephemeral messages. It performs no I/O and +// holds no CRDT state, so the server wiring only has to move bytes and act on +// the returned decisions; document authority stays with pkg/automerge and peer +// authentication stays with the server connection. +// +// A ServerSession is not safe for concurrent use; drive it from one connection +// goroutine. +type ServerSession struct { + config ServerConfig + + joined bool + remotePeerID string + + // highestCount tracks, per ephemeral session, the greatest count applied. + // The protocol guarantees a session's count strictly increases, so a count + // at or below the highest already seen is a gossip duplicate. This bounds + // memory to one entry per session rather than one per message. + highestCount map[string]uint64 +} + +// NewServerSession creates a server session. ServerPeerID must be set. +func NewServerSession(config ServerConfig) (*ServerSession, error) { + if config.ServerPeerID == "" { + return nil, fmt.Errorf("server session requires a server peer id") + } + + return &ServerSession{ + config: config, + highestCount: make(map[string]uint64), + }, nil +} + +// Handshake is the outcome of processing a client join frame. +type Handshake struct { + // Reply is the CBOR frame to send back: a peer frame when Accepted, or an + // error frame when not. It is always sent; when not Accepted the socket + // should then be closed. + Reply []byte + // Accepted reports whether the connection may proceed. + Accepted bool + // RemotePeerID is the client's peer id from the join frame. It is + // peer-chosen and must not be treated as an authenticated identity. + RemotePeerID string +} + +// Accept processes the client's join frame and produces the server's reply. A +// malformed frame returns an error. A well-formed join that does not offer a +// supported protocol version returns an accepted=false handshake carrying an +// error frame to send before closing. +func (s *ServerSession) Accept(joinData []byte) (Handshake, error) { + if s.joined { + return Handshake{}, fmt.Errorf("session already completed its handshake") + } + + join, err := DecodeJoinFrame(joinData) + if err != nil { + return Handshake{}, err + } + + if !join.SupportsV1() { + reply, encodeErr := EncodeErrorFrame(ErrorFrame{ + Type: FrameError, + SenderID: s.config.ServerPeerID, + TargetID: join.SenderID, + Message: "unsupported protocol version", + }) + if encodeErr != nil { + return Handshake{}, encodeErr + } + + return Handshake{Reply: reply, Accepted: false, RemotePeerID: join.SenderID}, nil + } + + reply, err := EncodePeerFrame(PeerFrame{ + Type: FramePeer, + SenderID: s.config.ServerPeerID, + TargetID: join.SenderID, + PeerMetadata: s.config.PeerMetadata, + SelectedProtocolVersion: ProtocolV1, + }) + if err != nil { + return Handshake{}, err + } + + s.joined = true + s.remotePeerID = join.SenderID + + return Handshake{Reply: reply, Accepted: true, RemotePeerID: join.SenderID}, nil +} + +// InboundKind classifies a routed frame for the server wiring. +type InboundKind int + +const ( + // InboundSync is a sync or request message to feed into the peer's SyncState. + InboundSync InboundKind = iota + // InboundEphemeral is a gossiped payload to fan out to the room. + InboundEphemeral + // InboundDocUnavailable reports the peer does not have the document. + InboundDocUnavailable + // InboundIgnored is a frame the gateway does not act on (for example the + // remote-heads messages a single-authority server does not need). + InboundIgnored +) + +// Inbound is the decision for one received document frame. +type Inbound struct { + Kind InboundKind + Message Message + // Duplicate is true for an ephemeral message already seen for its session, + // which must not be re-applied or re-broadcast. + Duplicate bool +} + +// Receive routes a document frame received after the handshake. Sync and request +// frames are returned for the caller to apply to the peer's SyncState; ephemeral +// frames are de-duplicated by session and count; the remote-heads control +// messages are ignored by a single-authority gateway. +func (s *ServerSession) Receive(frameData []byte) (Inbound, error) { + if !s.joined { + return Inbound{}, fmt.Errorf("received a document frame before the handshake completed") + } + + kind, err := FrameKind(frameData) + if err != nil { + return Inbound{}, err + } + + switch MessageType(kind) { + case MessageSync, MessageRequest: + message, err := DecodeMessage(frameData) + if err != nil { + return Inbound{}, err + } + + return Inbound{Kind: InboundSync, Message: message}, nil + case MessageEphemeral: + message, err := DecodeMessage(frameData) + if err != nil { + return Inbound{}, err + } + + return Inbound{ + Kind: InboundEphemeral, + Message: message, + Duplicate: s.seenEphemeral(message), + }, nil + case MessageDocUnavailable: + message, err := DecodeMessage(frameData) + if err != nil { + return Inbound{}, err + } + + return Inbound{Kind: InboundDocUnavailable, Message: message}, nil + default: + // remote-subscription-change, remote-heads-changed, or anything a newer + // peer introduces: acknowledged as a valid frame but not acted on. + return Inbound{Kind: InboundIgnored}, nil + } +} + +// seenEphemeral records an ephemeral message and reports whether it is a gossip +// duplicate. A message whose count is at or below the highest already recorded +// for its session has been seen; the protocol guarantees counts increase. +func (s *ServerSession) seenEphemeral(message Message) bool { + highest, ok := s.highestCount[message.SessionID] + if ok && message.Count <= highest { + return true + } + + s.highestCount[message.SessionID] = message.Count + + return false +} + +// RemotePeerID returns the client's peer id once the handshake has completed. +func (s *ServerSession) RemotePeerID() string { + return s.remotePeerID +} diff --git a/pkg/automerge/collaboration/session_test.go b/pkg/automerge/collaboration/session_test.go new file mode 100644 index 0000000000..7fe9edfad0 --- /dev/null +++ b/pkg/automerge/collaboration/session_test.go @@ -0,0 +1,189 @@ +// 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 collaboration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestSession(t *testing.T) *ServerSession { + t.Helper() + + session, err := NewServerSession(ServerConfig{ServerPeerID: "server"}) + require.NoError(t, err) + + return session +} + +// TestServerSession_AcceptsJavaScriptJoin drives the handshake with the exact +// join frame the JavaScript client emits and checks the peer reply decodes. +func TestServerSession_AcceptsJavaScriptJoin(t *testing.T) { + t.Parallel() + + join := decodeBase64(t, readFixture[wireFixture](t, "wire-join.json").FrameCBORBase64) + + session := newTestSession(t) + handshake, err := session.Accept(join) + require.NoError(t, err) + + assert.True(t, handshake.Accepted) + assert.Equal(t, "peer-a", handshake.RemotePeerID) + assert.Equal(t, "peer-a", session.RemotePeerID()) + + peer, err := DecodePeerFrame(handshake.Reply) + require.NoError(t, err) + assert.Equal(t, "server", peer.SenderID) + assert.Equal(t, "peer-a", peer.TargetID) + assert.Equal(t, ProtocolV1, peer.SelectedProtocolVersion) +} + +// TestServerSession_RejectsUnsupportedVersion returns an error frame, not an +// error, and does not complete the handshake. +func TestServerSession_RejectsUnsupportedVersion(t *testing.T) { + t.Parallel() + + join, err := marshal(JoinFrame{ + Type: FrameJoin, + SenderID: "peer-a", + SupportedProtocolVersions: []string{"999"}, + }) + require.NoError(t, err) + + session := newTestSession(t) + handshake, err := session.Accept(join) + require.NoError(t, err) + assert.False(t, handshake.Accepted) + + errorFrame, err := DecodeErrorFrame(handshake.Reply) + require.NoError(t, err) + assert.Equal(t, "peer-a", errorFrame.TargetID) + assert.NotEmpty(t, errorFrame.Message) +} + +// TestServerSession_RequiresHandshakeFirst refuses document frames before join. +func TestServerSession_RequiresHandshakeFirst(t *testing.T) { + t.Parallel() + + sync, err := EncodeMessage(Message{ + Type: MessageSync, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc", Data: []byte{1}, + }) + require.NoError(t, err) + + session := newTestSession(t) + _, err = session.Receive(sync) + assert.Error(t, err) +} + +func acceptedSession(t *testing.T) *ServerSession { + t.Helper() + + session := newTestSession(t) + join := decodeBase64(t, readFixture[wireFixture](t, "wire-join.json").FrameCBORBase64) + _, err := session.Accept(join) + require.NoError(t, err) + + return session +} + +// TestServerSession_RoutesSync classifies a sync frame for the SyncState. +func TestServerSession_RoutesSync(t *testing.T) { + t.Parallel() + + session := acceptedSession(t) + + sync := decodeBase64(t, readFixture[wireFixture](t, "wire-sync.json").FrameCBORBase64) + inbound, err := session.Receive(sync) + require.NoError(t, err) + assert.Equal(t, InboundSync, inbound.Kind) + assert.Equal(t, "4NMNnkMhL2wRfvHYuG1uxN", inbound.Message.DocumentID) + assert.Equal(t, []byte{0, 1, 2, 3}, inbound.Message.Data) +} + +// TestServerSession_DeduplicatesEphemeral drops a repeated (session,count). +func TestServerSession_DeduplicatesEphemeral(t *testing.T) { + t.Parallel() + + session := acceptedSession(t) + + payload, err := EncodePresence(PresenceMessage{Type: PresenceHeartbeat}) + require.NoError(t, err) + + frame := func(count uint64) []byte { + data, err := EncodeMessage(Message{ + Type: MessageEphemeral, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc", SessionID: "s", Count: count, Data: payload, + }) + require.NoError(t, err) + + return data + } + + first, err := session.Receive(frame(1)) + require.NoError(t, err) + assert.Equal(t, InboundEphemeral, first.Kind) + assert.False(t, first.Duplicate) + + second, err := session.Receive(frame(2)) + require.NoError(t, err) + assert.False(t, second.Duplicate) + + replay, err := session.Receive(frame(2)) + require.NoError(t, err) + assert.True(t, replay.Duplicate, "a repeated count is a gossip duplicate") + + older, err := session.Receive(frame(1)) + require.NoError(t, err) + assert.True(t, older.Duplicate, "a lower count is already covered") + + // A different session with the same count is not a duplicate. + otherSession, err := EncodeMessage(Message{ + Type: MessageEphemeral, SenderID: "peer-a", TargetID: "server", + DocumentID: "doc", SessionID: "s2", Count: 1, Data: payload, + }) + require.NoError(t, err) + + other, err := session.Receive(otherSession) + require.NoError(t, err) + assert.False(t, other.Duplicate) +} + +// TestServerSession_IgnoresRemoteHeads treats unrecognised control frames as +// valid-but-ignored rather than errors. +func TestServerSession_IgnoresRemoteHeads(t *testing.T) { + t.Parallel() + + session := acceptedSession(t) + + frame, err := marshal(map[string]any{ + "type": "remote-heads-changed", + "senderId": "peer-a", + "targetId": "server", + }) + require.NoError(t, err) + + inbound, err := session.Receive(frame) + require.NoError(t, err) + assert.Equal(t, InboundIgnored, inbound.Kind) +} diff --git a/pkg/automerge/collaboration/testdata/ephemeral-goodbye.json b/pkg/automerge/collaboration/testdata/ephemeral-goodbye.json new file mode 100644 index 0000000000..067b986270 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/ephemeral-goodbye.json @@ -0,0 +1,13 @@ +{ + "description": "Ephemeral repo message wrapping a presence goodbye", + "message": { + "type": "ephemeral", + "senderId": "peer-a", + "targetId": "peer-b", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "sessionId": "session-a", + "count": 1, + "data": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWdnb29kYnll" + }, + "payloadCborBase64": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWdnb29kYnll" +} diff --git a/pkg/automerge/collaboration/testdata/ephemeral-heartbeat.json b/pkg/automerge/collaboration/testdata/ephemeral-heartbeat.json new file mode 100644 index 0000000000..af46e19d7d --- /dev/null +++ b/pkg/automerge/collaboration/testdata/ephemeral-heartbeat.json @@ -0,0 +1,13 @@ +{ + "description": "Ephemeral repo message wrapping a presence heartbeat", + "message": { + "type": "ephemeral", + "senderId": "peer-a", + "targetId": "peer-b", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "sessionId": "session-a", + "count": 1, + "data": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWloZWFydGJlYXQ=" + }, + "payloadCborBase64": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWloZWFydGJlYXQ=" +} diff --git a/pkg/automerge/collaboration/testdata/ephemeral-snapshot.json b/pkg/automerge/collaboration/testdata/ephemeral-snapshot.json new file mode 100644 index 0000000000..d4a0ece769 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/ephemeral-snapshot.json @@ -0,0 +1,13 @@ +{ + "description": "Ephemeral repo message wrapping a presence snapshot", + "message": { + "type": "ephemeral", + "senderId": "peer-a", + "targetId": "peer-b", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "sessionId": "session-a", + "count": 1, + "data": "uQABal9fcHJlc2VuY2W5AAJkdHlwZWhzbmFwc2hvdGVzdGF0ZbkAAWZjdXJzb3K5AAJmYW5jaG9yYWFkaGVhZGFi" + }, + "payloadCborBase64": "uQABal9fcHJlc2VuY2W5AAJkdHlwZWhzbmFwc2hvdGVzdGF0ZbkAAWZjdXJzb3K5AAJmYW5jaG9yYWFkaGVhZGFi" +} diff --git a/pkg/automerge/collaboration/testdata/ephemeral-update.json b/pkg/automerge/collaboration/testdata/ephemeral-update.json new file mode 100644 index 0000000000..579fe6f6bd --- /dev/null +++ b/pkg/automerge/collaboration/testdata/ephemeral-update.json @@ -0,0 +1,13 @@ +{ + "description": "Ephemeral repo message wrapping a presence update", + "message": { + "type": "ephemeral", + "senderId": "peer-a", + "targetId": "peer-b", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "sessionId": "session-a", + "count": 1, + "data": "uQABal9fcHJlc2VuY2W5AANkdHlwZWZ1cGRhdGVnY2hhbm5lbGZjdXJzb3JldmFsdWW5AAJmYW5jaG9yYWFkaGVhZGFi" + }, + "payloadCborBase64": "uQABal9fcHJlc2VuY2W5AANkdHlwZWZ1cGRhdGVnY2hhbm5lbGZjdXJzb3JldmFsdWW5AAJmYW5jaG9yYWFkaGVhZGFi" +} diff --git a/pkg/automerge/collaboration/testdata/presence-goodbye.json b/pkg/automerge/collaboration/testdata/presence-goodbye.json new file mode 100644 index 0000000000..c9815a56cf --- /dev/null +++ b/pkg/automerge/collaboration/testdata/presence-goodbye.json @@ -0,0 +1,10 @@ +{ + "description": "Presence goodbye envelope CBOR-encoded as an ephemeral payload", + "marker": "__presence", + "envelope": { + "__presence": { + "type": "goodbye" + } + }, + "cborBase64": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWdnb29kYnll" +} diff --git a/pkg/automerge/collaboration/testdata/presence-heartbeat.json b/pkg/automerge/collaboration/testdata/presence-heartbeat.json new file mode 100644 index 0000000000..d91dfa6aa2 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/presence-heartbeat.json @@ -0,0 +1,10 @@ +{ + "description": "Presence heartbeat envelope CBOR-encoded as an ephemeral payload", + "marker": "__presence", + "envelope": { + "__presence": { + "type": "heartbeat" + } + }, + "cborBase64": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWloZWFydGJlYXQ=" +} diff --git a/pkg/automerge/collaboration/testdata/presence-roundtrip.json b/pkg/automerge/collaboration/testdata/presence-roundtrip.json new file mode 100644 index 0000000000..0634777d12 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/presence-roundtrip.json @@ -0,0 +1,15 @@ +{ + "description": "CBOR round-trip of a presence update envelope", + "envelope": { + "__presence": { + "type": "update", + "channel": "cursor", + "value": { + "anchor": "AAEC", + "head": "AwQF" + } + } + }, + "cborBase64": "uQABal9fcHJlc2VuY2W5AANkdHlwZWZ1cGRhdGVnY2hhbm5lbGZjdXJzb3JldmFsdWW5AAJmYW5jaG9yZEFBRUNkaGVhZGRBd1FG", + "decodesEqual": true +} diff --git a/pkg/automerge/collaboration/testdata/presence-snapshot.json b/pkg/automerge/collaboration/testdata/presence-snapshot.json new file mode 100644 index 0000000000..11b7ecf400 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/presence-snapshot.json @@ -0,0 +1,16 @@ +{ + "description": "Presence snapshot envelope CBOR-encoded as an ephemeral payload", + "marker": "__presence", + "envelope": { + "__presence": { + "type": "snapshot", + "state": { + "cursor": { + "anchor": "a", + "head": "b" + } + } + } + }, + "cborBase64": "uQABal9fcHJlc2VuY2W5AAJkdHlwZWhzbmFwc2hvdGVzdGF0ZbkAAWZjdXJzb3K5AAJmYW5jaG9yYWFkaGVhZGFi" +} diff --git a/pkg/automerge/collaboration/testdata/presence-update.json b/pkg/automerge/collaboration/testdata/presence-update.json new file mode 100644 index 0000000000..c57f788573 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/presence-update.json @@ -0,0 +1,15 @@ +{ + "description": "Presence update envelope CBOR-encoded as an ephemeral payload", + "marker": "__presence", + "envelope": { + "__presence": { + "type": "update", + "channel": "cursor", + "value": { + "anchor": "a", + "head": "b" + } + } + }, + "cborBase64": "uQABal9fcHJlc2VuY2W5AANkdHlwZWZ1cGRhdGVnY2hhbm5lbGZjdXJzb3JldmFsdWW5AAJmYW5jaG9yYWFkaGVhZGFi" +} diff --git a/pkg/automerge/collaboration/testdata/wire-ephemeral.json b/pkg/automerge/collaboration/testdata/wire-ephemeral.json new file mode 100644 index 0000000000..ced72c0a9a --- /dev/null +++ b/pkg/automerge/collaboration/testdata/wire-ephemeral.json @@ -0,0 +1,13 @@ +{ + "description": "Framed ephemeral message carrying a presence heartbeat payload", + "message": { + "type": "ephemeral", + "senderId": "peer-a", + "targetId": "server", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "sessionId": "session-a", + "count": 1, + "data": "uQABal9fcHJlc2VuY2W5AAFkdHlwZWloZWFydGJlYXQ=" + }, + "frameCborBase64": "uQAHZHR5cGVpZXBoZW1lcmFsaHNlbmRlcklkZnBlZXItYWh0YXJnZXRJZGZzZXJ2ZXJqZG9jdW1lbnRJZHY0Tk1ObmtNaEwyd1JmdkhZdUcxdXhOaXNlc3Npb25JZGlzZXNzaW9uLWFlY291bnQBZGRhdGFYILkAAWpfX3ByZXNlbmNluQABZHR5cGVpaGVhcnRiZWF0" +} diff --git a/pkg/automerge/collaboration/testdata/wire-error.json b/pkg/automerge/collaboration/testdata/wire-error.json new file mode 100644 index 0000000000..f887e916cf --- /dev/null +++ b/pkg/automerge/collaboration/testdata/wire-error.json @@ -0,0 +1,10 @@ +{ + "description": "Server error frame before closing the socket", + "message": { + "type": "error", + "senderId": "server", + "targetId": "peer-a", + "message": "unauthorized" + }, + "frameCborBase64": "uQAEZHR5cGVlZXJyb3Joc2VuZGVySWRmc2VydmVyaHRhcmdldElkZnBlZXItYWdtZXNzYWdlbHVuYXV0aG9yaXplZA==" +} diff --git a/pkg/automerge/collaboration/testdata/wire-join.json b/pkg/automerge/collaboration/testdata/wire-join.json new file mode 100644 index 0000000000..e7fa1af7b6 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/wire-join.json @@ -0,0 +1,14 @@ +{ + "description": "Client join handshake frame", + "message": { + "type": "join", + "senderId": "peer-a", + "peerMetadata": { + "isEphemeral": false + }, + "supportedProtocolVersions": [ + "1" + ] + }, + "frameCborBase64": "uQAEZHR5cGVkam9pbmhzZW5kZXJJZGZwZWVyLWFscGVlck1ldGFkYXRhuQABa2lzRXBoZW1lcmFs9HgZc3VwcG9ydGVkUHJvdG9jb2xWZXJzaW9uc4FhMQ==" +} diff --git a/pkg/automerge/collaboration/testdata/wire-peer.json b/pkg/automerge/collaboration/testdata/wire-peer.json new file mode 100644 index 0000000000..e27d638a50 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/wire-peer.json @@ -0,0 +1,13 @@ +{ + "description": "Server peer handshake reply frame", + "message": { + "type": "peer", + "senderId": "server", + "targetId": "peer-a", + "peerMetadata": { + "isEphemeral": false + }, + "selectedProtocolVersion": "1" + }, + "frameCborBase64": "uQAFZHR5cGVkcGVlcmhzZW5kZXJJZGZzZXJ2ZXJodGFyZ2V0SWRmcGVlci1hbHBlZXJNZXRhZGF0YbkAAWtpc0VwaGVtZXJhbPR3c2VsZWN0ZWRQcm90b2NvbFZlcnNpb25hMQ==" +} diff --git a/pkg/automerge/collaboration/testdata/wire-sync.json b/pkg/automerge/collaboration/testdata/wire-sync.json new file mode 100644 index 0000000000..4997e7c991 --- /dev/null +++ b/pkg/automerge/collaboration/testdata/wire-sync.json @@ -0,0 +1,11 @@ +{ + "description": "Framed sync message carrying opaque Automerge sync bytes", + "message": { + "type": "sync", + "senderId": "peer-a", + "targetId": "server", + "documentId": "4NMNnkMhL2wRfvHYuG1uxN", + "data": "AAECAw==" + }, + "frameCborBase64": "uQAFZHR5cGVkc3luY2hzZW5kZXJJZGZwZWVyLWFodGFyZ2V0SWRmc2VydmVyamRvY3VtZW50SWR2NE5NTm5rTWhMMndSZnZIWXVHMXV4TmRkYXRhRAABAgM=" +} diff --git a/pkg/automerge/collaboration/transport.go b/pkg/automerge/collaboration/transport.go new file mode 100644 index 0000000000..51f8863c40 --- /dev/null +++ b/pkg/automerge/collaboration/transport.go @@ -0,0 +1,205 @@ +// 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 collaboration + +import "fmt" + +// ProtocolV1 is the only automerge-repo WebSocket protocol version this package +// speaks. +const ProtocolV1 = "1" + +// Handshake frame types exchanged before document messages flow. +const ( + // FrameJoin is the client's first frame, announcing its peer id and the + // protocol versions it supports. + FrameJoin = "join" + // FramePeer is the server's reply, selecting a protocol version and + // advertising the server peer id. + FramePeer = "peer" + // FrameError is sent by either side to report a fatal error immediately + // before closing the socket. + FrameError = "error" +) + +// PeerMetadata is the optional metadata a peer presents in the handshake. It is +// not identity: the peer id and metadata are peer-chosen, so a gateway +// authenticates the connection out of band and never trusts these as a user. +type PeerMetadata struct { + StorageID string `cbor:"storageId,omitempty"` + IsEphemeral bool `cbor:"isEphemeral,omitempty"` +} + +// JoinFrame is the client handshake frame. It has no target id because it is +// sent before the client knows the server's peer id. +type JoinFrame struct { + Type string `cbor:"type"` + SenderID string `cbor:"senderId"` + PeerMetadata PeerMetadata `cbor:"peerMetadata"` + SupportedProtocolVersions []string `cbor:"supportedProtocolVersions"` +} + +// PeerFrame is the server's reply to a join frame. +type PeerFrame struct { + Type string `cbor:"type"` + SenderID string `cbor:"senderId"` + TargetID string `cbor:"targetId"` + PeerMetadata PeerMetadata `cbor:"peerMetadata"` + SelectedProtocolVersion string `cbor:"selectedProtocolVersion"` +} + +// ErrorFrame reports a fatal error; the sender closes the socket after it. +type ErrorFrame struct { + Type string `cbor:"type"` + SenderID string `cbor:"senderId"` + TargetID string `cbor:"targetId"` + Message string `cbor:"message"` +} + +// NewJoinFrame builds a client join frame advertising ProtocolV1. +func NewJoinFrame(senderID string, metadata PeerMetadata) JoinFrame { + return JoinFrame{ + Type: FrameJoin, + SenderID: senderID, + PeerMetadata: metadata, + SupportedProtocolVersions: []string{ProtocolV1}, + } +} + +// EncodeJoinFrame encodes a client join frame. +func EncodeJoinFrame(frame JoinFrame) ([]byte, error) { + if frame.Type != FrameJoin { + return nil, fmt.Errorf("join frame has type %q", frame.Type) + } + + if frame.SenderID == "" { + return nil, fmt.Errorf("join frame is missing a sender id") + } + + if len(frame.SupportedProtocolVersions) == 0 { + return nil, fmt.Errorf("join frame lists no supported protocol versions") + } + + return marshal(frame) +} + +// EncodePeerFrame encodes a server peer reply frame. +func EncodePeerFrame(frame PeerFrame) ([]byte, error) { + if frame.Type != FramePeer { + return nil, fmt.Errorf("peer frame has type %q", frame.Type) + } + + if frame.SenderID == "" || frame.TargetID == "" { + return nil, fmt.Errorf("peer frame is missing a sender or target id") + } + + if frame.SelectedProtocolVersion == "" { + return nil, fmt.Errorf("peer frame selected no protocol version") + } + + return marshal(frame) +} + +// EncodeErrorFrame encodes an error frame. +func EncodeErrorFrame(frame ErrorFrame) ([]byte, error) { + if frame.Type != FrameError { + return nil, fmt.Errorf("error frame has type %q", frame.Type) + } + + return marshal(frame) +} + +// frameType peeks only the discriminator so a reader can route a frame to the +// right decoder without decoding it twice into the wrong shape. +type frameType struct { + Type string `cbor:"type"` +} + +// FrameKind returns the type discriminator of a raw WebSocket frame. +func FrameKind(data []byte) (string, error) { + var peek frameType + if err := unmarshal(data, &peek); err != nil { + return "", fmt.Errorf("cannot read frame type: %w", err) + } + + if peek.Type == "" { + return "", fmt.Errorf("frame is missing a type") + } + + return peek.Type, nil +} + +// DecodeJoinFrame decodes and validates a client join frame, returning whether +// it supports ProtocolV1. +func DecodeJoinFrame(data []byte) (JoinFrame, error) { + var frame JoinFrame + if err := unmarshal(data, &frame); err != nil { + return JoinFrame{}, fmt.Errorf("cannot decode join frame: %w", err) + } + + if frame.Type != FrameJoin { + return JoinFrame{}, fmt.Errorf("expected a join frame, got %q", frame.Type) + } + + if frame.SenderID == "" { + return JoinFrame{}, fmt.Errorf("join frame is missing a sender id") + } + + return frame, nil +} + +// SupportsV1 reports whether the join frame offers ProtocolV1. +func (f JoinFrame) SupportsV1() bool { + for _, version := range f.SupportedProtocolVersions { + if version == ProtocolV1 { + return true + } + } + + return false +} + +// DecodePeerFrame decodes and validates a server peer frame. +func DecodePeerFrame(data []byte) (PeerFrame, error) { + var frame PeerFrame + if err := unmarshal(data, &frame); err != nil { + return PeerFrame{}, fmt.Errorf("cannot decode peer frame: %w", err) + } + + if frame.Type != FramePeer { + return PeerFrame{}, fmt.Errorf("expected a peer frame, got %q", frame.Type) + } + + return frame, nil +} + +// DecodeErrorFrame decodes an error frame. +func DecodeErrorFrame(data []byte) (ErrorFrame, error) { + var frame ErrorFrame + if err := unmarshal(data, &frame); err != nil { + return ErrorFrame{}, fmt.Errorf("cannot decode error frame: %w", err) + } + + if frame.Type != FrameError { + return ErrorFrame{}, fmt.Errorf("expected an error frame, got %q", frame.Type) + } + + return frame, nil +} diff --git a/pkg/automerge/collaboration/transport_test.go b/pkg/automerge/collaboration/transport_test.go new file mode 100644 index 0000000000..e31edfcdbb --- /dev/null +++ b/pkg/automerge/collaboration/transport_test.go @@ -0,0 +1,150 @@ +// 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 collaboration + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type wireFixture struct { + Description string `json:"description"` + FrameCBORBase64 string `json:"frameCborBase64"` +} + +// TestDecodeHandshakeFrames_MatchJavaScriptBytes decodes the exact handshake +// frames the pinned WebSocket adapter emits. +func TestDecodeHandshakeFrames_MatchJavaScriptBytes(t *testing.T) { + t.Parallel() + + t.Run("join", func(t *testing.T) { + t.Parallel() + + data := decodeBase64(t, readFixture[wireFixture](t, "wire-join.json").FrameCBORBase64) + + kind, err := FrameKind(data) + require.NoError(t, err) + assert.Equal(t, FrameJoin, kind) + + frame, err := DecodeJoinFrame(data) + require.NoError(t, err) + assert.Equal(t, "peer-a", frame.SenderID) + assert.True(t, frame.SupportsV1()) + assert.False(t, frame.PeerMetadata.IsEphemeral) + }) + + t.Run("peer", func(t *testing.T) { + t.Parallel() + + data := decodeBase64(t, readFixture[wireFixture](t, "wire-peer.json").FrameCBORBase64) + + frame, err := DecodePeerFrame(data) + require.NoError(t, err) + assert.Equal(t, "server", frame.SenderID) + assert.Equal(t, "peer-a", frame.TargetID) + assert.Equal(t, ProtocolV1, frame.SelectedProtocolVersion) + }) + + t.Run("error", func(t *testing.T) { + t.Parallel() + + data := decodeBase64(t, readFixture[wireFixture](t, "wire-error.json").FrameCBORBase64) + + frame, err := DecodeErrorFrame(data) + require.NoError(t, err) + assert.Equal(t, "unauthorized", frame.Message) + }) +} + +// TestDecodeFramedMessages_MatchJavaScriptBytes decodes the framed document +// messages the adapter emits and confirms the ephemeral frame carries a +// presence payload our presence codec reads. +func TestDecodeFramedMessages_MatchJavaScriptBytes(t *testing.T) { + t.Parallel() + + t.Run("sync", func(t *testing.T) { + t.Parallel() + + data := decodeBase64(t, readFixture[wireFixture](t, "wire-sync.json").FrameCBORBase64) + + kind, err := FrameKind(data) + require.NoError(t, err) + assert.Equal(t, string(MessageSync), kind) + + message, err := DecodeMessage(data) + require.NoError(t, err) + assert.Equal(t, MessageSync, message.Type) + assert.Equal(t, "4NMNnkMhL2wRfvHYuG1uxN", message.DocumentID) + assert.Equal(t, []byte{0, 1, 2, 3}, message.Data) + }) + + t.Run("ephemeral carries presence", func(t *testing.T) { + t.Parallel() + + data := decodeBase64(t, readFixture[wireFixture](t, "wire-ephemeral.json").FrameCBORBase64) + + message, err := DecodeMessage(data) + require.NoError(t, err) + assert.Equal(t, MessageEphemeral, message.Type) + + presence, err := DecodePresence(message.Data) + require.NoError(t, err) + assert.Equal(t, PresenceHeartbeat, presence.Type) + }) +} + +// TestHandshakeFrames_RoundTrip encodes then decodes each handshake frame. +func TestHandshakeFrames_RoundTrip(t *testing.T) { + t.Parallel() + + join, err := EncodeJoinFrame(NewJoinFrame("peer-a", PeerMetadata{IsEphemeral: true})) + require.NoError(t, err) + + decodedJoin, err := DecodeJoinFrame(join) + require.NoError(t, err) + assert.True(t, decodedJoin.SupportsV1()) + assert.True(t, decodedJoin.PeerMetadata.IsEphemeral) + + peer, err := EncodePeerFrame(PeerFrame{ + Type: FramePeer, + SenderID: "server", + TargetID: "peer-a", + SelectedProtocolVersion: ProtocolV1, + }) + require.NoError(t, err) + + decodedPeer, err := DecodePeerFrame(peer) + require.NoError(t, err) + assert.Equal(t, ProtocolV1, decodedPeer.SelectedProtocolVersion) +} + +// TestEncodeFrames_Validation rejects malformed handshake frames. +func TestEncodeFrames_Validation(t *testing.T) { + t.Parallel() + + _, err := EncodeJoinFrame(JoinFrame{Type: FrameJoin, SupportedProtocolVersions: []string{ProtocolV1}}) + assert.Error(t, err, "join without a sender must be rejected") + + _, err = EncodePeerFrame(PeerFrame{Type: FramePeer, SenderID: "s", TargetID: "c"}) + assert.Error(t, err, "peer without a selected version must be rejected") +} diff --git a/pkg/automerge/compressed_save_parity_test.go b/pkg/automerge/compressed_save_parity_test.go new file mode 100644 index 0000000000..2233fe695c --- /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.Save(ctx, automerge.NoCompress()) + 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 := range items { + value, err := list.ScalarAt(ctx, uint64(i)) + require.NoError(t, err) + assert.Equal(t, uint64(i), value.Uint) + } + }) + } +} diff --git a/pkg/automerge/concurrent_determinism_test.go b/pkg/automerge/concurrent_determinism_test.go new file mode 100644 index 0000000000..44206051a4 --- /dev/null +++ b/pkg/automerge/concurrent_determinism_test.go @@ -0,0 +1,366 @@ +// 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 ( + "bytes" + "context" + "fmt" + "math/rand" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestConcurrentEncodingIsByteIdentical is the strict determinism gate: two +// peers per engine make independent edits over a shared base and merge. The two +// engines must not merely converge to the same values, they must encode each +// change to the same bytes and therefore agree on every hash. +// +// This is stronger than convergence and it is what caught the conflicted-put +// divergence: assigning the value a conflicted key already resolves to must emit +// a delete of the losing siblings, not a fresh assignment and not nothing. +func TestConcurrentEncodingIsByteIdentical(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const ( + scenarios = 120 + roundEdits = 12 + rounds = 3 + ) + + for scenario := range scenarios { + random := rand.New(rand.NewSource(int64(scenario))) + + nativeLeft := newStressActor(t, ctx, rustParityEngines()[0], 0x01) + referenceLeft := newStressActor(t, ctx, rustParityEngines()[1], 0x01) + + seedSaved, err := nativeLeft.document.Save(ctx) + require.NoError(t, err) + + referenceSeed, err := referenceLeft.document.Save(ctx) + require.NoError(t, err) + + nativeRight := forkStressActor(t, ctx, rustParityEngines()[0], seedSaved, 0x02) + referenceRight := forkStressActor(t, ctx, rustParityEngines()[1], referenceSeed, 0x02) + + peers := []*stressActor{nativeLeft, referenceLeft, nativeRight, referenceRight} + drainIncremental(t, ctx, peers) + + for round := range rounds { + editStressActor(t, ctx, random, nativeLeft, referenceLeft, roundEdits) + editStressActor(t, ctx, random, nativeRight, referenceRight, roundEdits) + + assertIdenticalEncoding(t, ctx, scenario, round, "left", nativeLeft, referenceLeft) + assertIdenticalEncoding(t, ctx, scenario, round, "right", nativeRight, referenceRight) + + mergeDocuments(t, ctx, nativeLeft.document, nativeRight.document) + mergeDocuments(t, ctx, referenceLeft.document, referenceRight.document) + + assert.Equalf(t, + canonicalDocument(t, ctx, referenceLeft.document), + canonicalDocument(t, ctx, nativeLeft.document), + "scenario %d round %d merged document diverged", scenario, round, + ) + + drainIncremental(t, ctx, peers) + } + } +} + +// TestPutMatchesReferenceOnConflictedKey pins the exact rule the strict gate +// discovered, for a map key and a list element holding concurrent values. +func TestPutMatchesReferenceOnConflictedKey(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + value string + }{ + {name: "equal to the winning value", value: "R"}, + {name: "equal to the losing value", value: "L"}, + {name: "a new value", value: "N"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + native := conflictedMapDocument(t, ctx, rustParityEngines()[0]) + reference := conflictedMapDocument(t, ctx, rustParityEngines()[1]) + + put := automerge.Scalar{Type: automerge.ScalarTypeString, String: tt.value} + require.NoError(t, native.Root().PutScalar(ctx, "key", put)) + require.NoError(t, reference.Root().PutScalar(ctx, "key", put)) + + assert.Equal(t, + commitAndEncode(t, ctx, reference), + commitAndEncode(t, ctx, native), + ) + assert.Equal(t, + mapKeySignature(t, ctx, reference, "key"), + mapKeySignature(t, ctx, native, "key"), + ) + }) + } +} + +func TestPutMatchesReferenceOnConflictedListElement(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, value := range []string{"R", "L", "N"} { + t.Run("put "+value, func(t *testing.T) { + t.Parallel() + + native, nativeList := conflictedListDocument(t, ctx, rustParityEngines()[0]) + reference, referenceList := conflictedListDocument(t, ctx, rustParityEngines()[1]) + + put := automerge.Scalar{Type: automerge.ScalarTypeString, String: value} + require.NoError(t, nativeList.PutScalarAt(ctx, 0, put)) + require.NoError(t, referenceList.PutScalarAt(ctx, 0, put)) + + assert.Equal(t, + commitAndEncode(t, ctx, reference), + commitAndEncode(t, ctx, native), + ) + assert.Equal(t, + listElementSignature(t, ctx, reference, referenceList), + listElementSignature(t, ctx, native, nativeList), + ) + }) + } +} + +// mapKeySignature describes both the resolved value and the full conflict set at +// a key, so a put that only appears to work is still caught. +func mapKeySignature( + t *testing.T, + ctx context.Context, + document *automerge.Document, + key string, +) string { + t.Helper() + + winner, err := document.Root().Scalar(ctx, key) + require.NoError(t, err) + + conflicts, err := document.Root().Scalars(ctx, key) + require.NoError(t, err) + + return fmt.Sprintf("heads=%v winner=%s conflicts=%s", + sortedHeadHex(t, ctx, document), + canonicalScalar(winner), + describeScalars(conflicts), + ) +} + +func listElementSignature( + t *testing.T, + ctx context.Context, + document *automerge.Document, + list *automerge.Object, +) string { + t.Helper() + + winner, err := list.ScalarAt(ctx, 0) + require.NoError(t, err) + + conflicts, err := list.ScalarsAt(ctx, 0) + require.NoError(t, err) + + return fmt.Sprintf("heads=%v winner=%s conflicts=%s", + sortedHeadHex(t, ctx, document), + canonicalScalar(winner), + describeScalars(conflicts), + ) +} + +func describeScalars(values []automerge.Scalar) string { + rendered := make([]string, 0, len(values)) + for _, value := range values { + rendered = append(rendered, canonicalScalar(value)) + } + + sort.Strings(rendered) + + return strings.Join(rendered, "|") +} + +func drainIncremental(t *testing.T, ctx context.Context, actors []*stressActor) { + t.Helper() + + for _, peer := range actors { + _, err := peer.document.SaveIncremental(ctx) + require.NoError(t, err) + } +} + +func assertIdenticalEncoding( + t *testing.T, + ctx context.Context, + scenario, round int, + side string, + nativeActor, referenceActor *stressActor, +) { + t.Helper() + + nativeBytes, err := nativeActor.document.SaveIncremental(ctx) + require.NoError(t, err) + + referenceBytes, err := referenceActor.document.SaveIncremental(ctx) + require.NoError(t, err) + + assert.Truef(t, + bytes.Equal(nativeBytes, referenceBytes), + "scenario %d round %d %s peer encoded its change differently (%d native bytes, %d reference bytes)", + scenario, round, side, len(nativeBytes), len(referenceBytes), + ) +} + +// conflictedMapDocument returns a document whose "key" property holds two +// concurrent values, "L" from the first actor and the winning "R" from the +// second. +func conflictedMapDocument( + t *testing.T, + ctx context.Context, + engine rustParityEngine, +) *automerge.Document { + t.Helper() + + base, err := engine.open(ctx, actor(0x01)) + require.NoError(t, err) + closeDocument(t, base) + + require.NoError(t, base.Root().PutScalar(ctx, "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "base"})) + _, err = base.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + left, right := forkPair(t, ctx, engine, base) + + require.NoError(t, left.Root().PutScalar(ctx, "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"})) + _, err = left.Commit(ctx, "left", commitTime) + require.NoError(t, err) + + require.NoError(t, right.Root().PutScalar(ctx, "key", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "R"})) + _, err = right.Commit(ctx, "right", commitTime) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + + _, err = left.SaveIncremental(ctx) + require.NoError(t, err) + + return left +} + +func conflictedListDocument( + t *testing.T, + ctx context.Context, + engine rustParityEngine, +) (*automerge.Document, *automerge.Object) { + t.Helper() + + base, err := engine.open(ctx, actor(0x01)) + 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: "base"})) + _, err = base.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + left, right := forkPair(t, ctx, engine, base) + + leftList, err := left.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, leftList.PutScalarAt(ctx, 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "L"})) + _, err = left.Commit(ctx, "left", commitTime) + require.NoError(t, err) + + rightList, err := right.Root().Object(ctx, "list") + require.NoError(t, err) + require.NoError(t, rightList.PutScalarAt(ctx, 0, + automerge.Scalar{Type: automerge.ScalarTypeString, String: "R"})) + _, err = right.Commit(ctx, "right", commitTime) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + + _, err = left.SaveIncremental(ctx) + require.NoError(t, err) + + return left, leftList +} + +func forkPair( + t *testing.T, + ctx context.Context, + engine rustParityEngine, + base *automerge.Document, +) (*automerge.Document, *automerge.Document) { + t.Helper() + + saved, err := base.Save(ctx) + require.NoError(t, err) + + left, err := engine.load(ctx, saved, actor(0x01)) + require.NoError(t, err) + closeDocument(t, left) + + right, err := engine.load(ctx, saved, actor(0x02)) + require.NoError(t, err) + closeDocument(t, right) + + return left, right +} + +// commitAndEncode commits the pending operations and returns the encoded change +// bytes, or a marker when the engine had nothing to record. +func commitAndEncode(t *testing.T, ctx context.Context, document *automerge.Document) string { + t.Helper() + + if _, err := document.Commit(ctx, "put", commitTime); err != nil { + return "no change: " + err.Error() + } + + encoded, err := document.SaveIncremental(ctx) + require.NoError(t, err) + + return fmt.Sprintf("%x", encoded) +} diff --git a/pkg/automerge/conformance_test.go b/pkg/automerge/conformance_test.go new file mode 100644 index 0000000000..e399c83d52 --- /dev/null +++ b/pkg/automerge/conformance_test.go @@ -0,0 +1,766 @@ +// 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 ( + "bytes" + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "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" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" +) + +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"` + 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"` + Sync string `json:"sync"` + } +) + +func runOracle(t *testing.T, request oracleRequest) oracleResponse { + t.Helper() + + oracle := os.Getenv("AUTOMERGE_JS_ORACLE") + if oracle == "" { + t.Skip("AUTOMERGE_JS_ORACLE is not configured") + } + + input, err := json.Marshal(request) + require.NoError(t, err) + + command := exec.CommandContext(context.Background(), "node", oracle) + command.Stdin = bytes.NewReader(input) + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + + var response oracleResponse + require.NoError(t, json.Unmarshal(output, &response)) + + 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() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(1)) + 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, "Hello 😀")) + hash, err := document.Commit(ctx, "Create in Go", commitTime) + require.NoError(t, err) + data, err := document.Save(ctx) + require.NoError(t, err) + + response := runOracle( + t, + oracleRequest{ + Action: "inspect", + Document: base64.StdEncoding.EncodeToString(data), + }, + ) + + assert.Equal(t, "Hello 😀", response.Body) + 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() + + actorID := actor(9) + response := runOracle( + t, + oracleRequest{ + Action: "create", + Actor: hex.EncodeToString(actorID[:]), + Message: "Create in JavaScript", + Text: "Hello from JavaScript 😀", + Timestamp: commitTime.Unix(), + }, + ) + + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + document, err := automerge.Load(context.Background(), data, actor(10)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.Text(context.Background(), "body") + require.NoError(t, err) + value, err := text.String(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Hello from JavaScript 😀", value) + + heads, err := document.Heads(context.Background()) + require.NoError(t, err) + require.Len(t, heads, 1) + assert.Equal(t, response.Heads[0], heads[0].String()) + + nativeDocument, err := native.Decode(data) + require.NoError(t, err) + nativeState, err := native.NewStateFromDocument(nativeDocument) + require.NoError(t, err) + nativeText, err := nativeState.Text("body") + require.NoError(t, err) + 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() + + actorID := actor(11) + response := runOracle( + t, + oracleRequest{ + Action: "createRichText", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + document, err := automerge.LoadReference(context.Background(), data, actor(12)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.Text(context.Background(), "body") + require.NoError(t, err) + spans, err := text.Spans(context.Background()) + require.NoError(t, err) + require.Len(t, spans, 2) + assert.Equal(t, automerge.SpanTypeBlock, spans[0].Type) + assert.Equal(t, "heading", spans[0].Block["type"]) + attrs, ok := spans[0].Block["attrs"].(map[string]any) + require.True(t, ok) + assert.Equal(t, float64(2), attrs["level"]) + assert.Equal(t, automerge.SpanTypeText, spans[1].Type) + assert.Equal(t, "Policy", spans[1].Text) + assert.Equal(t, true, spans[1].Marks["strong"]) + + nativeDocument, err := automerge.Load(context.Background(), data, actor(14)) + 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, spans, nativeSpans) +} + +func TestConformance_NativeParsesJavaScriptChange(t *testing.T) { + t.Parallel() + + actorID := actor(13) + response := runOracle( + t, + oracleRequest{ + Action: "createChange", + Actor: hex.EncodeToString(actorID[:]), + Message: "Create policy", + Timestamp: commitTime.Unix(), + }, + ) + + 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, actorID[:], change.Actor.Bytes()) + assert.Equal(t, uint64(1), change.Sequence) + assert.Equal(t, uint64(1), change.StartOp) + assert.Equal(t, commitTime.Unix(), change.Time) + assert.Equal(t, "Create policy", change.Message) + assert.Empty(t, change.Dependencies) + require.NotNil(t, change.Hash) + assert.Equal(t, response.Heads[0], change.Hash.String()) + + operations := change.Operations + require.Len(t, operations, 7) + assert.Equal(t, native.ActionMakeText, operations[0].Action) + assert.True(t, operations[0].Object.IsRoot) + require.NotNil(t, operations[0].Key.Property) + assert.Equal(t, "title", *operations[0].Key.Property) + assert.Equal(t, uint64(1), operations[0].ID.Counter) + assert.Equal(t, native.ActorID(string(actorID[:])), operations[0].ID.Actor) + assert.Equal(t, native.ActionSet, operations[1].Action) + assert.Equal(t, uint64(1), operations[1].Object.OpID.Counter) + assert.True(t, operations[1].Key.IsHead) + require.NotNil(t, operations[1].Value) + assert.Equal(t, "P", operations[1].Value.String) + require.NotNil(t, operations[6].Key.Element) + assert.Equal(t, uint64(6), operations[6].Key.Element.Counter) + require.NotNil(t, operations[6].Value) + assert.Equal(t, "y", operations[6].Value.String) + + state := native.NewState() + require.NoError(t, state.ApplyChange(change)) + title, err := state.Text("title") + require.NoError(t, err) + assert.Equal(t, "Policy", title) + + nativeEncoded, err := native.EncodeChange(change) + require.NoError(t, err) + inspection := runOracle( + t, + oracleRequest{ + Action: "inspectChange", + Change: base64.StdEncoding.EncodeToString(nativeEncoded), + }, + ) + assert.Equal(t, "Policy", inspection.Body) + assert.Equal(t, "Create policy", inspection.Message) + require.NotNil(t, change.Hash) + 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() + + actorA := actor(20) + actorB := actor(21) + actorC := actor(22) + response := runOracle( + t, + oracleRequest{ + Action: "createConcurrentChanges", + Actor: hex.EncodeToString(actorA[:]), + ActorB: hex.EncodeToString(actorB[:]), + ActorC: hex.EncodeToString(actorC[:]), + }, + ) + require.Len(t, response.Changes, 3) + + var ( + combined []byte + rawChanges [][]byte + ) + + for _, encoded := range response.Changes { + data, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + + combined = append(combined, data...) + rawChanges = append(rawChanges, data) + } + + decoded, err := native.Decode(combined) + require.NoError(t, err) + require.Len(t, decoded.Changes, 3) + changes := []*native.Change{ + &decoded.Changes[0], + &decoded.Changes[1], + &decoded.Changes[2], + } + + leftFirst := native.NewState() + require.NoError(t, leftFirst.ApplyChange(changes[0])) + require.NoError(t, leftFirst.ApplyChange(changes[1])) + require.NoError(t, leftFirst.ApplyChange(changes[2])) + + rightFirst := native.NewState() + require.NoError(t, rightFirst.ApplyChange(changes[0])) + require.NoError(t, rightFirst.ApplyChange(changes[2])) + require.NoError(t, rightFirst.ApplyChange(changes[1])) + + leftText, err := leftFirst.Text("body") + require.NoError(t, err) + rightText, err := rightFirst.Text("body") + require.NoError(t, err) + assert.Equal(t, response.Body, leftText) + assert.Equal(t, response.Body, rightText) + + leftHeads := leftFirst.Heads() + rightHeads := rightFirst.Heads() + + require.Len(t, leftHeads, 2) + require.Len(t, rightHeads, 2) + assert.ElementsMatch( + t, + response.Heads, + []string{ + hex.EncodeToString(leftHeads[0][:]), + hex.EncodeToString(leftHeads[1][:]), + }, + ) + assert.Equal(t, leftHeads, rightHeads) + + backend, err := native.LoadEngine(context.Background(), rawChanges[0]) + require.NoError(t, err) + _, err = backend.Merge(context.Background(), rawChanges[1]) + require.NoError(t, err) + + before, err := backend.Heads(context.Background()) + require.NoError(t, err) + + saved, err := backend.Save(context.Background(), true, true) + require.NoError(t, err) + + // Save now writes a compacted document rather than embedding change bytes, so + // the guarantee is that reloading it reproduces the same frontier. + reloaded, err := native.LoadEngine(context.Background(), saved) + require.NoError(t, err) + + after, err := reloaded.Heads(context.Background()) + require.NoError(t, err) + assert.Equal(t, before, after) +} + +func TestConformance_NativeSyncMessageRoundTrip(t *testing.T) { + t.Parallel() + + actorID := actor(30) + response := runOracle( + t, + oracleRequest{ + Action: "createSyncMessage", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Sync) + require.NoError(t, err) + + message, err := native.ParseSyncMessage(data) + require.NoError(t, err) + assert.Contains( + t, + []native.SyncMessageVersion{ + native.SyncMessageVersion1, + native.SyncMessageVersion2, + }, + message.Version, + ) + require.Len(t, message.Heads, 1) + assert.Equal(t, response.Heads[0], hex.EncodeToString(message.Heads[0][:])) + + encoded, err := message.Encode() + require.NoError(t, err) + assert.Equal(t, data, encoded) +} + +func TestConformance_NativeComplexRichTextSpans(t *testing.T) { + t.Parallel() + + actorID := actor(31) + response := runOracle( + t, + oracleRequest{ + Action: "createComplexRichText", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference( + context.Background(), + data, + actor(32), + ) + 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(33)) + 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_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() + + actorID := actor(34) + response := runOracle( + t, + oracleRequest{ + Action: "createTableRichText", + Actor: hex.EncodeToString(actorID[:]), + }, + ) + data, err := base64.StdEncoding.DecodeString(response.Document) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference( + context.Background(), + data, + actor(35), + ) + 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(36)) + 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) + + content, err := automergeprosemirror.Render(nativeSpans) + require.NoError(t, err) + + var document struct { + Content []struct { + Type string `json:"type"` + Content []struct { + Type string `json:"type"` + Content []json.RawMessage `json:"content"` + } `json:"content"` + } `json:"content"` + } + require.NoError(t, json.Unmarshal([]byte(content), &document)) + require.Len(t, document.Content, 1) + assert.Equal(t, "table", document.Content[0].Type) + require.Len(t, document.Content[0].Content, 2) + assert.Equal(t, "tableRow", document.Content[0].Content[0].Type) + assert.Len(t, document.Content[0].Content[0].Content, 2) +} 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..9172d1a05d --- /dev/null +++ b/pkg/automerge/convert_string_to_text_parity_test.go @@ -0,0 +1,194 @@ +// 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", + func(ctx context.Context, data []byte, actorID automerge.ActorID) (*automerge.Document, error) { + return automerge.Load(ctx, data, actorID, automerge.ConvertStringsToText()) + }, + }, + { + "reference", + func(ctx context.Context, data []byte, actorID automerge.ActorID) (*automerge.Document, error) { + return automerge.LoadReference(ctx, data, actorID, automerge.ConvertStringsToText()) + }, + }, + } +} + +// 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(ctx) + 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(ctx) + 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..f1303b4e6e --- /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(ctx) + 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..5a7d88f4fd --- /dev/null +++ b/pkg/automerge/counter_patch_parity_test.go @@ -0,0 +1,103 @@ +// 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..0bfe9efd39 --- /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.engine.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.engine.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.engine.TextAt(ctx, t.handle, engineHashes(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.engine.TextCursorMovingAt( + ctx, + t.handle, + uint32(index), + move == CursorMoveBefore, + engineHashes(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.engine.TextCursorPosition( + ctx, + t.handle, + cursor, + ) + if err != nil { + return fmt.Errorf("cannot resolve Automerge text cursor: %w", err) + } + + if err := t.document.engine.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..72c7e16d82 --- /dev/null +++ b/pkg/automerge/diff_marks_parity_test.go @@ -0,0 +1,513 @@ +// 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..adf00cdcdb --- /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(ctx) + 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(ctx) + 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/differential_stress_test.go b/pkg/automerge/differential_stress_test.go new file mode 100644 index 0000000000..0db661f29a --- /dev/null +++ b/pkg/automerge/differential_stress_test.go @@ -0,0 +1,580 @@ +// 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 drives randomized differential stress tests: identical random +// operation sequences are applied in lockstep to a native Go document and a +// Rust/WASM reference document, and their full observable state must match after +// every commit, after save/load round trips, and after concurrent merges. It +// exercises the incremental sequence and map caches under insertion, deletion, +// replacement, reload, and merge, which is where cache-invalidation bugs hide. +// Mark values are intentionally excluded (a known native mark-boundary defect is +// tracked separately); text content, list and map values, block structure, and +// heads are all compared. + +package automerge_test + +import ( + "context" + "fmt" + "math/rand" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +const stressListKey = "list" + +const stressTextKey = "text" + +var stressMapKeys = []string{"a", "b", "c", "d"} + +// canonicalDocument renders the comparable observable state of a document: its +// sorted heads, every root map key with its scalar value, the list contents, +// and the text contents. Nested object identities are excluded because they are +// engine-specific; their materialized values are what must agree. +func canonicalDocument(t *testing.T, ctx context.Context, document *automerge.Document) string { + t.Helper() + + return "heads:" + strings.Join(sortedHeadHex(t, ctx, document), ",") + "\n" + + canonicalValues(t, ctx, document) +} + +// canonicalValues renders the materialized state without heads, for the few +// assertions that deliberately compare only observable values. +func canonicalValues(t *testing.T, ctx context.Context, document *automerge.Document) string { + t.Helper() + + var builder strings.Builder + + keys, err := document.Root().Keys(ctx) + require.NoError(t, err) + sort.Strings(keys) + + for _, key := range keys { + if key == stressListKey || key == stressTextKey { + continue + } + + value, err := document.Root().Scalar(ctx, key) + require.NoError(t, err) + fmt.Fprintf(&builder, "map[%s]=%s\n", key, canonicalScalar(value)) + } + + list, err := document.Root().Object(ctx, stressListKey) + require.NoError(t, err) + + length, err := list.Len(ctx) + require.NoError(t, err) + + builder.WriteString("list:") + + for index := range length { + value, err := list.ScalarAt(ctx, index) + require.NoError(t, err) + builder.WriteString(canonicalScalar(value)) + builder.WriteString(",") + } + + builder.WriteString("\n") + + text, err := document.Text(ctx, stressTextKey) + require.NoError(t, err) + + content, err := text.String(ctx) + require.NoError(t, err) + fmt.Fprintf(&builder, "text:%q\n", content) + + return builder.String() +} + +func canonicalScalar(value automerge.Scalar) string { + switch value.Type { + case automerge.ScalarTypeString: + return "s:" + value.String + case automerge.ScalarTypeInt: + return fmt.Sprintf("i:%d", value.Int) + case automerge.ScalarTypeUint: + return fmt.Sprintf("u:%d", value.Uint) + case automerge.ScalarTypeBoolean: + return fmt.Sprintf("b:%t", value.Bool) + case automerge.ScalarTypeNull: + return "null" + default: + return string(value.Type) + } +} + +// stressActor drives one document (native or reference) so an identical +// operation can be applied to both engines in lockstep. +type stressActor struct { + document *automerge.Document + list *automerge.Object + text *automerge.Text +} + +func newStressActor(t *testing.T, ctx context.Context, engine rustParityEngine, id byte) *stressActor { + t.Helper() + + document, err := engine.open(ctx, actor(id)) + require.NoError(t, err) + closeDocument(t, document) + + list, err := document.Root().CreateObject(ctx, stressListKey, automerge.ObjectTypeList) + require.NoError(t, err) + + text, err := document.CreateText(ctx, stressTextKey) + require.NoError(t, err) + + _, err = document.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + return &stressActor{document: document, list: list, text: text} +} + +func randomScalar(random *rand.Rand) automerge.Scalar { + switch random.Intn(4) { + case 0: + return automerge.Scalar{Type: automerge.ScalarTypeString, String: randomLetters(random, 1+random.Intn(4))} + case 1: + return automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(random.Intn(1000)) - 500} + case 2: + return automerge.Scalar{Type: automerge.ScalarTypeUint, Uint: uint64(random.Intn(1000))} + default: + return automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: random.Intn(2) == 0} + } +} + +// applyStressOperation performs one deterministic operation, described by the +// random source, on the given actor. The same random draws applied to two +// actors produce identical operations, keeping the engines in lockstep. +func applyStressOperation( + t *testing.T, + ctx context.Context, + actor *stressActor, + op stressOperation, +) { + t.Helper() + + switch op.kind { + case opMapPut: + require.NoError(t, actor.document.Root().PutScalar(ctx, op.key, op.scalar)) + case opMapDelete: + require.NoError(t, actor.document.Root().DeleteKey(ctx, op.key)) + case opListInsert: + require.NoError(t, actor.list.InsertScalar(ctx, op.index, op.scalar)) + case opListPut: + require.NoError(t, actor.list.PutScalarAt(ctx, op.index, op.scalar)) + case opListDelete: + require.NoError(t, actor.list.DeleteIndex(ctx, op.index)) + case opTextInsert: + require.NoError(t, actor.text.Splice(ctx, uint32(op.index), 0, op.value)) + case opTextDelete: + require.NoError(t, actor.text.Splice(ctx, uint32(op.index), int32(op.count), "")) + } +} + +type stressOpKind int + +const ( + opMapPut stressOpKind = iota + opMapDelete + opListInsert + opListPut + opListDelete + opTextInsert + opTextDelete +) + +type stressOperation struct { + kind stressOpKind + key string + index uint64 + count uint64 + value string + scalar automerge.Scalar +} + +// nextStressOperation chooses a valid operation from the current model lengths so +// indices stay in range for both engines. +func nextStressOperation(random *rand.Rand, listLen, textLen uint64, present map[string]bool) stressOperation { + for { + switch random.Intn(7) { + case 0: + return stressOperation{kind: opMapPut, key: stressMapKeys[random.Intn(len(stressMapKeys))], scalar: randomScalar(random)} + case 1: + existing := make([]string, 0, len(present)) + for key := range present { + existing = append(existing, key) + } + + if len(existing) == 0 { + continue + } + + sort.Strings(existing) + + return stressOperation{kind: opMapDelete, key: existing[random.Intn(len(existing))]} + case 2: + return stressOperation{kind: opListInsert, index: uint64(random.Intn(int(listLen) + 1)), scalar: randomScalar(random)} + case 3: + if listLen == 0 { + continue + } + + return stressOperation{kind: opListPut, index: uint64(random.Intn(int(listLen))), scalar: randomScalar(random)} + case 4: + if listLen == 0 { + continue + } + + return stressOperation{kind: opListDelete, index: uint64(random.Intn(int(listLen)))} + case 5: + return stressOperation{kind: opTextInsert, index: uint64(random.Intn(int(textLen) + 1)), value: randomLetters(random, 1+random.Intn(4))} + case 6: + if textLen == 0 { + continue + } + + deleteCount := 1 + random.Intn(int(textLen)) + + return stressOperation{kind: opTextDelete, index: uint64(random.Intn(int(textLen) - deleteCount + 1)), count: uint64(deleteCount)} + } + } +} + +// TestDifferentialStress_SingleDocument applies identical random operations to a +// native and a reference document and asserts their observable state matches +// after every commit, with periodic save/load round trips. +func TestDifferentialStress_SingleDocument(t *testing.T) { + t.Parallel() + + ctx := context.Background() + random := rand.New(rand.NewSource(0x1f2e3d4c5b6a7988)) + + const ( + scenarios = 40 + steps = 60 + ) + + for scenario := range scenarios { + native := newStressActor(t, ctx, rustParityEngines()[0], 0x01) + reference := newStressActor(t, ctx, rustParityEngines()[1], 0x01) + + require.Equal(t, + canonicalDocument(t, ctx, reference.document), + canonicalDocument(t, ctx, native.document), + "scenario %d seed diverged", scenario, + ) + + var listLen, textLen uint64 + + present := make(map[string]bool) + + for step := range steps { + op := nextStressOperation(random, listLen, textLen, present) + + applyStressOperation(t, ctx, native, op) + applyStressOperation(t, ctx, reference, op) + + switch op.kind { + case opMapPut: + present[op.key] = true + case opMapDelete: + delete(present, op.key) + } + + nativeCommitted := tolerantCommit(t, ctx, native.document) + referenceCommitted := tolerantCommit(t, ctx, reference.document) + require.Equal(t, referenceCommitted, nativeCommitted, + "scenario %d step %d op %+v commit divergence", scenario, step, op) + + listLen = mustLen(t, ctx, native.list) + textLen = mustTextLen(t, ctx, native.text) + + require.Equal(t, + canonicalDocument(t, ctx, reference.document), + canonicalDocument(t, ctx, native.document), + "scenario %d step %d op %+v diverged", scenario, step, op, + ) + } + + // A save/load round trip rebuilds every cache from scratch; the reloaded + // state must equal the pre-save state and still match the reference. + saved, err := native.document.Save(ctx) + require.NoError(t, err) + + reloaded, err := automerge.Load(ctx, saved, actor(0x09)) + require.NoError(t, err) + closeDocument(t, reloaded) + + require.Equal(t, + canonicalDocument(t, ctx, native.document), + canonicalDocument(t, ctx, reloaded), + "scenario %d reload diverged", scenario, + ) + } +} + +// tolerantCommit commits and reports whether a change was produced. A step whose +// operation was a no-op (for example, a put of the identical value) yields no +// change on both engines, which is expected and not an error. +func tolerantCommit(t *testing.T, ctx context.Context, document *automerge.Document) bool { + t.Helper() + + _, err := document.Commit(ctx, "step", commitTime) + if err != nil { + require.ErrorContains(t, err, "no operations") + + return false + } + + return true +} + +// TestDifferentialStress_ConcurrentMerge drives two forked peers per engine +// through independent random edits and then merges them in both directions, +// asserting the merged native and reference documents converge to identical +// observable state. This stresses cache invalidation across merges and the +// determinism of conflict resolution. +func TestDifferentialStress_ConcurrentMerge(t *testing.T) { + t.Parallel() + + ctx := context.Background() + random := rand.New(rand.NewSource(0x6c5f4e3d2c1b0a99)) + + const ( + scenarios = 40 + roundEdits = 12 + rounds = 3 + ) + + for scenario := range scenarios { + nativeLeft := newStressActor(t, ctx, rustParityEngines()[0], 0x01) + referenceLeft := newStressActor(t, ctx, rustParityEngines()[1], 0x01) + + // Both peers start from the same seeded document so their concurrent + // edits build on shared history. + seedSaved, err := nativeLeft.document.Save(ctx) + require.NoError(t, err) + + nativeRight := forkStressActor(t, ctx, rustParityEngines()[0], seedSaved, 0x02) + + referenceSeed, err := referenceLeft.document.Save(ctx) + require.NoError(t, err) + + referenceRight := forkStressActor(t, ctx, rustParityEngines()[1], referenceSeed, 0x02) + + for round := range rounds { + editStressActor(t, ctx, random, nativeLeft, referenceLeft, roundEdits) + editStressActor(t, ctx, random, nativeRight, referenceRight, roundEdits) + + mergeDocuments(t, ctx, nativeLeft.document, nativeRight.document) + mergeDocuments(t, ctx, referenceLeft.document, referenceRight.document) + + require.Equal(t, + canonicalDocument(t, ctx, referenceLeft.document), + canonicalDocument(t, ctx, nativeLeft.document), + "scenario %d round %d merged state diverged", scenario, round, + ) + } + } +} + +func forkStressActor( + t *testing.T, + ctx context.Context, + engine rustParityEngine, + saved []byte, + id byte, +) *stressActor { + t.Helper() + + document, err := engine.load(ctx, saved, actor(id)) + require.NoError(t, err) + closeDocument(t, document) + + list, err := document.Root().Object(ctx, stressListKey) + require.NoError(t, err) + + text, err := document.Text(ctx, stressTextKey) + require.NoError(t, err) + + return &stressActor{document: document, list: list, text: text} +} + +// editStressActor applies the same random edits to a native and a reference peer +// so both engines diverge identically before a merge. +func editStressActor( + t *testing.T, + ctx context.Context, + random *rand.Rand, + native, reference *stressActor, + edits int, +) { + t.Helper() + + present := make(map[string]bool) + + for range edits { + op := nextStressOperation( + random, + mustLen(t, ctx, native.list), + mustTextLen(t, ctx, native.text), + present, + ) + + applyStressOperation(t, ctx, native, op) + applyStressOperation(t, ctx, reference, op) + + switch op.kind { + case opMapPut: + present[op.key] = true + case opMapDelete: + delete(present, op.key) + } + } + + tolerantCommit(t, ctx, native.document) + tolerantCommit(t, ctx, reference.document) +} + +func mergeDocuments(t *testing.T, ctx context.Context, left, right *automerge.Document) { + t.Helper() + + _, err := left.Merge(ctx, right) + require.NoError(t, err) + + _, err = right.Merge(ctx, left) + require.NoError(t, err) +} + +// FuzzDifferentialOperations drives the lockstep native/reference comparison +// from fuzzer-provided bytes so continuous fuzzing can keep exploring the +// operation space. Each byte selects and parameterizes one operation; after +// every commit the two engines must agree on materialized values. +func FuzzDifferentialOperations(f *testing.F) { + f.Add([]byte{0x05, 0x41, 0x05, 0x42, 0x02, 0x10, 0x00, 0x03, 0x00, 0x20}) + f.Add([]byte{0x02, 0x00, 0x11, 0x02, 0x01, 0x22, 0x06, 0x00, 0x33, 0x05, 0x00}) + + f.Fuzz(func(t *testing.T, script []byte) { + ctx := context.Background() + + native := newStressActor(t, ctx, rustParityEngines()[0], 0x01) + reference := newStressActor(t, ctx, rustParityEngines()[1], 0x01) + + present := make(map[string]bool) + + for cursor := 0; cursor+1 < len(script); cursor += 2 { + op := scriptOperation( + script[cursor], + script[cursor+1], + mustLen(t, ctx, native.list), + mustTextLen(t, ctx, native.text), + present, + ) + if op == nil { + continue + } + + applyStressOperation(t, ctx, native, *op) + applyStressOperation(t, ctx, reference, *op) + + switch op.kind { + case opMapPut: + present[op.key] = true + case opMapDelete: + delete(present, op.key) + } + + nativeCommitted := tolerantCommit(t, ctx, native.document) + referenceCommitted := tolerantCommit(t, ctx, reference.document) + require.Equal(t, referenceCommitted, nativeCommitted, "commit divergence for %+v", *op) + + require.Equal(t, + canonicalValues(t, ctx, reference.document), + canonicalValues(t, ctx, native.document), + "value divergence after %+v", *op, + ) + } + }) +} + +// scriptOperation decodes a two-byte instruction into a valid operation for the +// current model sizes, or nil when the instruction cannot form a valid one. +func scriptOperation(selector, param byte, listLen, textLen uint64, present map[string]bool) *stressOperation { + key := stressMapKeys[int(param)%len(stressMapKeys)] + scalar := automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(param)} + + switch selector % 7 { + case 0: + return &stressOperation{kind: opMapPut, key: key, scalar: scalar} + case 1: + if !present[key] { + return nil + } + + return &stressOperation{kind: opMapDelete, key: key} + case 2: + return &stressOperation{kind: opListInsert, index: uint64(param) % (listLen + 1), scalar: scalar} + case 3: + if listLen == 0 { + return nil + } + + return &stressOperation{kind: opListPut, index: uint64(param) % listLen, scalar: scalar} + case 4: + if listLen == 0 { + return nil + } + + return &stressOperation{kind: opListDelete, index: uint64(param) % listLen} + case 5: + return &stressOperation{kind: opTextInsert, index: uint64(param) % (textLen + 1), value: string(rune('a' + int(param)%26))} + case 6: + if textLen == 0 { + return nil + } + + return &stressOperation{kind: opTextDelete, index: uint64(param) % textLen, count: 1} + } + + return nil +} + +func mustLen(t *testing.T, ctx context.Context, object *automerge.Object) uint64 { + t.Helper() + + length, err := object.Len(ctx) + require.NoError(t, err) + + return length +} + +func mustTextLen(t *testing.T, ctx context.Context, text *automerge.Text) uint64 { + t.Helper() + + content, err := text.String(ctx) + require.NoError(t, err) + + return uint64(len([]rune(content))) +} diff --git a/pkg/automerge/document_save_parity_test.go b/pkg/automerge/document_save_parity_test.go new file mode 100644 index 0000000000..5365021f4a --- /dev/null +++ b/pkg/automerge/document_save_parity_test.go @@ -0,0 +1,215 @@ +// 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" +) + +// documentSaveScenario applies the same history to whichever engine it is given +// so a native snapshot can be held against the reference's byte for byte. +type documentSaveScenario struct { + name string + apply func(t *testing.T, ctx context.Context, document *automerge.Document) +} + +func documentSaveScenarios() []documentSaveScenario { + base := time.Unix(1786147200, 0).UTC() + + return []documentSaveScenario{ + { + name: "linear text", + apply: func(t *testing.T, ctx context.Context, document *automerge.Document) { + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + + for i := range 5 { + require.NoError(t, text.Splice(ctx, uint32(i), 0, "x")) + _, err = document.Commit(ctx, "edit", base.Add(time.Duration(i)*time.Second)) + require.NoError(t, err) + } + }, + }, + { + name: "map puts and delete", + apply: func(t *testing.T, ctx context.Context, document *automerge.Document) { + require.NoError(t, document.PutScalar(ctx, "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "first"})) + require.NoError(t, document.PutScalar(ctx, "keep", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "value"})) + _, err := document.Commit(ctx, "one", base) + require.NoError(t, err) + + require.NoError(t, document.PutScalar(ctx, "title", + automerge.Scalar{Type: automerge.ScalarTypeString, String: "second"})) + _, err = document.Commit(ctx, "two", base.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, document.Root().DeleteKey(ctx, "title")) + _, err = document.Commit(ctx, "three", base.Add(2*time.Second)) + require.NoError(t, err) + }, + }, + { + name: "counter increment", + apply: func(t *testing.T, ctx context.Context, document *automerge.Document) { + require.NoError(t, document.PutScalar(ctx, "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 5})) + _, err := document.Commit(ctx, "create", base) + require.NoError(t, err) + + require.NoError(t, document.Root().Increment(ctx, "counter", 3)) + _, err = document.Commit(ctx, "bump", base.Add(time.Second)) + require.NoError(t, err) + }, + }, + { + name: "marks and unmarks", + apply: func(t *testing.T, ctx context.Context, document *automerge.Document) { + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello brave world")) + _, err = document.Commit(ctx, "write", base) + require.NoError(t, err) + + require.NoError(t, text.Mark(ctx, 0, 5, "strong", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth)) + _, err = document.Commit(ctx, "mark", base.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, text.Unmark(ctx, 1, 3, "strong", automerge.MarkExpandNone)) + _, err = document.Commit(ctx, "unmark", base.Add(2*time.Second)) + require.NoError(t, err) + }, + }, + { + name: "text with deletion", + apply: func(t *testing.T, ctx context.Context, document *automerge.Document) { + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello brave world")) + _, err = document.Commit(ctx, "write", base) + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 5, 6, "")) + _, err = document.Commit(ctx, "trim", base.Add(time.Second)) + require.NoError(t, err) + }, + }, + } +} + +// TestDocumentSave_MatchesReferenceBytes requires the compacted snapshot the +// native engine writes to be the file the reference writes for the same +// history. Byte identity is the only check that proves the operation-set order, +// the column layout and the frontier all agree. +func TestDocumentSave_MatchesReferenceBytes(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, scenario := range documentSaveScenarios() { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + native, err := automerge.New(ctx, actor(41)) + require.NoError(t, err) + closeDocument(t, native) + + reference, err := automerge.NewReference(ctx, actor(41)) + require.NoError(t, err) + closeDocument(t, reference) + + scenario.apply(t, ctx, native) + scenario.apply(t, ctx, reference) + + expected, err := reference.Save(ctx) + require.NoError(t, err) + + actual, err := native.Save(ctx) + require.NoError(t, err) + + assert.Equal(t, expected, actual) + }) + } +} + +// TestDocumentSave_ReloadsIntoTheSameHistory checks a compacted snapshot is a +// complete history rather than only the right bytes: it must reload with every +// change reachable and be accepted by the reference. +func TestDocumentSave_ReloadsIntoTheSameHistory(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, scenario := range documentSaveScenarios() { + t.Run(scenario.name, func(t *testing.T) { + t.Parallel() + + document, err := automerge.New(ctx, actor(42)) + require.NoError(t, err) + closeDocument(t, document) + + scenario.apply(t, ctx, document) + + heads, err := document.Heads(ctx) + require.NoError(t, err) + + original, err := document.ChangesSince(ctx, nil) + require.NoError(t, err) + + snapshot, err := document.Save(ctx) + require.NoError(t, err) + + reloaded, err := automerge.Load(ctx, snapshot, actor(43)) + require.NoError(t, err) + closeDocument(t, reloaded) + + reloadedHeads, err := reloaded.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, heads, reloadedHeads) + + changes, err := reloaded.ChangesSince(ctx, nil) + require.NoError(t, err) + require.Len(t, changes, len(original)) + + for i := range changes { + assert.Equal(t, original[i].Hash, changes[i].Hash, "change %d", i) + assert.Equal(t, original[i].Bytes, changes[i].Bytes, "change %d bytes", i) + } + + adopted, err := automerge.LoadReference(ctx, snapshot, actor(44)) + require.NoError(t, err) + closeDocument(t, adopted) + + adoptedHeads, err := adopted.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, heads, adoptedHeads) + }) + } +} diff --git a/pkg/automerge/fuzz_test.go b/pkg/automerge/fuzz_test.go new file mode 100644 index 0000000000..39d38b1db2 --- /dev/null +++ b/pkg/automerge/fuzz_test.go @@ -0,0 +1,213 @@ +// 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" + "testing" + + "go.probo.inc/probo/pkg/automerge" +) + +func FuzzLoad(f *testing.F) { + f.Add([]byte{}) + f.Add([]byte("invalid")) + f.Add([]byte{0x85, 0x6f, 0x4a, 0x83}) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1024*1024 { + t.Skip() + } + + document, err := automerge.Load(context.Background(), data, actor(255)) + if err != nil { + return + } + + _ = 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..17878cafa6 --- /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.engine.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..72cac8ce66 --- /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(ctx) + 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(ctx) + 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/encoding/encoding.go b/pkg/automerge/internal/encoding/encoding.go new file mode 100644 index 0000000000..c018289355 --- /dev/null +++ b/pkg/automerge/internal/encoding/encoding.go @@ -0,0 +1,96 @@ +// 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 encoding contains the bounded binary primitives shared by Automerge +// storage, cursor, and protocol codecs. +package encoding + +import "fmt" + +type Reader struct { + data []byte + offset int +} + +func NewReader(data []byte) *Reader { return &Reader{data: data} } +func NewReaderAt(data []byte, offset int) *Reader { return &Reader{data: data, offset: offset} } +func (r *Reader) Offset() int { return r.offset } +func (r *Reader) Remaining() int { return len(r.data) - r.offset } +func (r *Reader) Byte() (byte, error) { + if r.Remaining() < 1 { + return 0, fmt.Errorf("unexpected end of data") + } + + value := r.data[r.offset] + r.offset++ + + return value, nil +} +func (r *Reader) Bytes(length uint64) ([]byte, error) { + if length > uint64(r.Remaining()) { + return nil, fmt.Errorf("need %d bytes, only %d remain", length, r.Remaining()) + } + + start := r.offset + r.offset += int(length) + + return r.data[start:r.offset], nil +} +func (r *Reader) ULEB() (uint64, error) { + var value uint64 + + for shift := uint(0); shift < 64; shift += 7 { + b, err := r.Byte() + if err != nil { + return 0, err + } + + if shift == 63 && b > 1 { + return 0, fmt.Errorf("ULEB128 overflow") + } + + value |= uint64(b&0x7f) << shift + if b&0x80 == 0 { + return value, nil + } + } + + return 0, fmt.Errorf("ULEB128 overflow") +} +func AppendULEB(data []byte, value uint64) []byte { + for value >= 0x80 { + data = append(data, byte(value)|0x80) + value >>= 7 + } + + return append(data, byte(value)) +} +func AppendLengthPrefixed(data, value []byte) []byte { + data = AppendULEB(data, uint64(len(value))) + return append(data, value...) +} +func DecodeLengthPrefixed(r *Reader) ([]byte, error) { + length, err := r.ULEB() + if err != nil { + return nil, err + } + + return r.Bytes(length) +} diff --git a/pkg/automerge/internal/encoding/encoding_test.go b/pkg/automerge/internal/encoding/encoding_test.go new file mode 100644 index 0000000000..e6c32be803 --- /dev/null +++ b/pkg/automerge/internal/encoding/encoding_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. + +package encoding + +import ( + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestULEBRoundTrip(t *testing.T) { + t.Parallel() + + for _, value := range []uint64{0, 1, 0x7f, 0x80, 0x3fff, 0x4000, math.MaxUint64} { + encoded := AppendULEB(nil, value) + decoded, err := NewReader(encoded).ULEB() + require.NoError(t, err) + assert.Equal(t, value, decoded) + } +} + +func TestReaderBounds(t *testing.T) { + t.Parallel() + + reader := NewReader([]byte{1, 2}) + _, err := reader.Bytes(3) + require.Error(t, err) + assert.Equal(t, 0, reader.Offset()) +} + +func TestLengthPrefixedRoundTrip(t *testing.T) { + t.Parallel() + + encoded := AppendLengthPrefixed(nil, []byte("value")) + decoded, err := DecodeLengthPrefixed(NewReader(encoded)) + require.NoError(t, err) + assert.Equal(t, []byte("value"), decoded) +} + +func FuzzReader(f *testing.F) { + f.Add([]byte{0}) + f.Add([]byte{0x80, 0x01}) + f.Add([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x01}) + + f.Fuzz(func(t *testing.T, data []byte) { + reader := NewReader(data) + + length, err := reader.ULEB() + if err != nil { + return + } + + _, _ = reader.Bytes(length) + }) +} diff --git a/pkg/automerge/internal/native/ARCHITECTURE.md b/pkg/automerge/internal/native/ARCHITECTURE.md new file mode 100644 index 0000000000..302147437f --- /dev/null +++ b/pkg/automerge/internal/native/ARCHITECTURE.md @@ -0,0 +1,70 @@ +# Native Automerge engine + +This package is the pure-Go Automerge engine. It is intentionally internal: +the stable API lives in `pkg/automerge`, and callers must not depend on engine +types or storage details. + +## File boundaries + +| Area | Files | Responsibility | +|---|---|---| +| Engine lifecycle | `engine.go` | Engine construction, load/save, incremental persistence and actor setup | +| Public object operations | `object.go` | Map/list scalar and object operations, counters, deletion, keys and length | +| Rich text API | `rich_text.go`, `text_diff.go` | Text splice, blocks, spans, marks, cursors and update-spans reconciliation | +| Transactions | `transaction.go` | Commit, rollback, isolate and integrate | +| Patches | `patch.go` | Current state, historical/current diffs and incremental patch generation | +| History and merge | `history.go` | Heads, dependency queries, changes, apply and merge, including orphan queues | +| Sync engine | `sync_engine.go` | Per-peer V2 synchronization state machine | +| Engine helpers | `engine_helpers.go` | Handle validation, sequence index resolution, scalar wire values and cursors | +| Materialized state | `state.go` | Change graph, pending/applied changes, map indexes and historical state | +| Sequence state | `sequence_state.go` | RGA order, sequence caches, conflicts and visible winners | +| Rich-text state | `rich_text_state.go` | Span/mark state machines, mark anchors and UTF-16 ranges | +| Hydration | `hydrate_state.go` | Recursive map/list materialization | +| Storage facade | `storage.go` | Delegation to the independent storage and encoding packages | +| Shared model facade | `model.go` | Aliases to the independent operation-set model package | + +## Internal package boundaries + +| Package | Responsibility | +|---|---| +| `internal/opset` | Dependency-free actor, operation, change, object, scalar and chunk model | +| `internal/encoding` | Bounded binary reader, ULEB128 and length-prefixed primitives | +| `internal/storage` | Automerge chunk/column encoding, decoding and graph validation | +| `internal/sync` | V1/V2 sync message wire codec and resource limits | +| `internal/native` | Mutable CRDT engine, materialized state, rich text, patches and sync orchestration | +| `internal/reference` | Rust/WASM differential oracle used only by parity tests | + +## Dependency direction + +The engine follows this direction: + +```text +pkg/automerge public API + ↓ +native Engine methods + ↓ +State / sequence / rich-text state + ↓ +internal/opset ← internal/encoding ← internal/storage + ↑ + internal/sync +``` + +Sync, patches and rich text may use common engine/state helpers, but storage +code must not depend on those higher-level features. Keep protocol state out of +the CRDT state graph, and keep materialization/diff concerns out of the storage +codec. + +## Refactoring rule + +Changes that move code between these files must be behavior-preserving and run +the strict gates: + +```sh +make audit-automerge-interop +make test-automerge-conformance +go test -race ./pkg/automerge/... +``` + +Byte identity is part of behavior: concurrent native/reference edits must +produce identical changes and heads, not merely converge to equal values. diff --git a/pkg/automerge/internal/native/document.go b/pkg/automerge/internal/native/document.go new file mode 100644 index 0000000000..19dfd8e2e4 --- /dev/null +++ b/pkg/automerge/internal/native/document.go @@ -0,0 +1,189 @@ +// 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 "slices" + +// compact serializes the whole history as one document chunk, the form save() +// produces in the other implementations, followed by any retained orphan changes +// as trailing change chunks (which is how a snapshot carries changes it cannot +// place in the operation set because their dependencies are missing). +// +// It reports ok=false, rather than an error, when the history cannot be +// compacted: while isolated, where the pinned view is not the whole history, or +// when the change graph is not internally consistent. The caller then falls back +// to the faithful change stream, which preserves every byte that was loaded. +func (b *Engine) compact(retainOrphans, deflate bool) ([]byte, bool, error) { + if b.isolationActive { + return nil, false, nil + } + + changes, ok := b.state.allChanges() + if !ok { + return nil, false, nil + } + + document := &Document{ + Changes: make([]Change, 0, len(changes)), + Heads: b.state.Heads(), + } + for _, change := range changes { + document.Changes = append(document.Changes, *change) + } + + data, err := EncodeDocument(document, b.state.documentOperationOrder(), deflate) + if err != nil { + return nil, false, err + } + + if retainOrphans { + for _, change := range orderedQueuedChanges(b.queuedChanges) { + data = append(data, maybeCompressChangeChunk(change.Raw, deflate)...) + } + } + + return data, true, nil +} + +// documentOperationOrder returns the operation-set order a document chunk is +// written in: the root map first, then every object in identifier order, with a +// map's operations grouped by property and a sequence's following the order a +// reader sees. Deletes are left out because a snapshot records them only as +// successors of what they removed. +func (s *State) documentOperationOrder() []OpID { + order := make([]OpID, 0, len(s.operations)) + + for _, object := range s.documentObjects() { + if object.IsRoot || isMapObject(s.operations[object.OpID].Action) { + order = append(order, s.mapObjectOrder(object)...) + + continue + } + + order = append(order, s.sequenceObjectOrder(object)...) + } + + return order +} + +// documentObjects lists the root map followed by every object the history +// creates, ordered by the identifier of the operation that made it. +func (s *State) documentObjects() []ObjectID { + objects := make([]ObjectID, 0) + + for id, operation := range s.operations { + if isObjectAction(operation.Action) { + objects = append(objects, ObjectID{OpID: id}) + } + } + + slices.SortFunc(objects, func(left, right ObjectID) int { + return left.OpID.Compare(right.OpID) + }) + + return append([]ObjectID{RootObject()}, objects...) +} + +func (s *State) mapObjectOrder(object ObjectID) []OpID { + byProperty := make(map[string][]OpID) + + for id, operation := range s.operations { + if operation.Object != object || + operation.Key.Property == nil || + operation.Action == ActionDelete { + continue + } + + property := *operation.Key.Property + byProperty[property] = append(byProperty[property], id) + } + + properties := make([]string, 0, len(byProperty)) + for property := range byProperty { + properties = append(properties, property) + } + + slices.Sort(properties) + + order := make([]OpID, 0, len(s.operations)) + + for _, property := range properties { + identifiers := byProperty[property] + + slices.SortFunc(identifiers, func(left, right OpID) int { + return left.Compare(right) + }) + + order = append(order, identifiers...) + } + + return order +} + +func (s *State) sequenceObjectOrder(object ObjectID) []OpID { + // Operations that address an element rather than create it, such as an + // overwrite, follow the element they target. + byElement := make(map[OpID][]OpID) + + for id, operation := range s.operations { + if operation.Object != object || + operation.Insert || + operation.Key.Element == nil || + operation.Action == ActionDelete { + continue + } + + element := *operation.Key.Element + byElement[element] = append(byElement[element], id) + } + + for element := range byElement { + slices.SortFunc(byElement[element], func(left, right OpID) int { + return left.Compare(right) + }) + } + + elements := s.insertOrder(object.OpID) + order := make([]OpID, 0, len(elements)) + + for _, element := range elements { + if operation, ok := s.operations[element]; ok && operation.Action != ActionDelete { + order = append(order, element) + } + + order = append(order, byElement[element]...) + } + + return order +} + +func isObjectAction(action Action) bool { + switch action { + case ActionMakeMap, ActionMakeList, ActionMakeText, ActionMakeTable: + return true + default: + return false + } +} + +func isMapObject(action Action) bool { + return action == ActionMakeMap || action == ActionMakeTable +} diff --git a/pkg/automerge/internal/native/engine.go b/pkg/automerge/internal/native/engine.go new file mode 100644 index 0000000000..29eaa1170c --- /dev/null +++ b/pkg/automerge/internal/native/engine.go @@ -0,0 +1,527 @@ +// 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 ( + "bytes" + "context" + "fmt" + "sort" + "strings" + "time" +) + +type Engine struct { + state *State + actor ActorID + nextOp uint64 + base []byte + appended [][]byte + saveCursor int + pending []Operation + objects map[uint32]ObjectID + nextHandle uint32 + syncStates map[uint32]*nativeSyncState + 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 + + // revision increases on every change to the committed history or the + // retained orphan set. The compacted save is cached against it so repeated + // saves of an unchanged document skip rebuilding the whole columnar + // document, which the collaboration snapshot path does on every request. + revision uint64 + saveCache saveCacheEntry +} + +// saveCacheEntry memoizes one compacted save. It is valid only for the exact +// revision and option combination it was built from. +type saveCacheEntry struct { + revision uint64 + retainOrphans bool + compress bool + valid bool + data []byte +} + +type nativeSyncState struct { + RemoteHeads [][32]byte `json:"remoteHeads"` + LastSentHeads [][32]byte `json:"lastSentHeads"` + LastSentNeed [][32]byte `json:"lastSentNeed"` + 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 NewEngine(ctx context.Context) (*Engine, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + actor, err := randomActorID() + if err != nil { + return nil, err + } + + base := encodeEmptyDocument() + + document, err := Decode(base) + if err != nil { + return nil, fmt.Errorf("cannot decode native empty document: %w", err) + } + + state, err := NewStateFromDocument(document) + if err != nil { + return nil, fmt.Errorf("cannot initialize native empty state: %w", err) + } + + return &Engine{ + 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: make(map[ChangeHash]*Change), + }, nil +} + +func LoadEngine(ctx context.Context, data []byte) (*Engine, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + 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 engine, ok, tolerantErr := loadEngineRetainingOrphans(data, err); ok { + return engine, tolerantErr + } + + return nil, fmt.Errorf("cannot decode native document: %w", err) + } + + state, err := NewStateFromDocument(document) + if err != nil { + return nil, fmt.Errorf("cannot initialize native document state: %w", err) + } + + actor, err := randomActorID() + if err != nil { + return nil, err + } + + return &Engine{ + state: state, + actor: actor, + nextOp: state.maxOpGlobal() + 1, + base: append([]byte(nil), data...), + objects: map[uint32]ObjectID{0: RootObject()}, + nextHandle: 1, + syncStates: make(map[uint32]*nativeSyncState), + nextSyncState: 1, + queuedChanges: make(map[ChangeHash]*Change), + }, nil +} + +// loadEngineRetainingOrphans 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 loadEngineRetainingOrphans( + data []byte, + strictErr error, +) (*Engine, 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 &Engine{ + 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 *Engine) Close(context.Context) error { + return nil +} + +// Save serializes the whole history as one compacted document chunk, the form +// save() produces in the Rust and JavaScript implementations. It replaces the +// change-by-change stream Go used to write, which grew without bound as a +// history accumulated commits. retainOrphans keeps queued changes whose +// dependencies are still missing so they survive a save/load round trip, and +// compress DEFLATEs the document columns and any trailing change chunks. +func (b *Engine) Save( + ctx context.Context, + retainOrphans bool, + compress bool, +) ([]byte, error) { + return b.save(ctx, retainOrphans, compress) +} + +func (b *Engine) 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 + } + } + + // Rebuilding the columnar document is by far the costliest part of a save, so + // an unchanged document returns the bytes built last time. The cache is keyed + // by the mutation revision and the option combination, and it is invalidated + // implicitly because every committed change advances the revision. + if cached, ok := b.cachedSave(retainOrphans, deflate); ok { + b.saveCursor = len(b.appended) + + return cached, nil + } + + // A compacted document chunk is the form every other implementation writes + // and is dramatically smaller than the change stream for a long history. It + // leaves the incremental cursor at the end, exactly as the stream save did, + // so a following SaveIncremental still emits only later changes. + if data, ok, err := b.compact(retainOrphans, deflate); err != nil { + return nil, err + } else if ok { + b.saveCursor = len(b.appended) + b.storeSave(retainOrphans, deflate, data) + + return data, nil + } + + data, err := b.streamSave(retainOrphans, deflate) + if err != nil { + return nil, err + } + + b.storeSave(retainOrphans, deflate, data) + + return data, nil +} + +// cachedSave returns a copy of the previously built save when it is still valid +// for this revision and option combination. A copy is returned because callers +// own the bytes and may retain or mutate them. +func (b *Engine) cachedSave(retainOrphans, compress bool) ([]byte, bool) { + if !b.saveCache.valid || + b.saveCache.revision != b.revision || + b.saveCache.retainOrphans != retainOrphans || + b.saveCache.compress != compress { + return nil, false + } + + return append([]byte(nil), b.saveCache.data...), true +} + +// storeSave records a freshly built save so an unchanged document can return it +// without rebuilding. The stored bytes are copied so a caller mutating the +// returned slice cannot corrupt the cache. +func (b *Engine) storeSave(retainOrphans, compress bool, data []byte) { + b.saveCache = saveCacheEntry{ + revision: b.revision, + retainOrphans: retainOrphans, + compress: compress, + valid: true, + data: append([]byte(nil), data...), + } +} + +// streamSave serializes the history as the loaded base followed by each change +// chunk since. It preserves the loaded bytes verbatim, including columns this +// version does not understand, and is the fallback when a history cannot be +// compacted (while isolated, or when the change graph is inconsistent). +func (b *Engine) streamSave(retainOrphans, deflate bool) ([]byte, error) { + total := len(b.base) + for _, change := range b.appended { + total += len(change) + } + + 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 := newReaderAt(raw, 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 *Engine) 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 *Engine) 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 *Engine) SetActor(ctx context.Context, value []byte) error { + if err := ctx.Err(); err != nil { + return err + } + + actor, err := NewActorID(value) + if err != nil { + return err + } + + if len(b.pending) > 0 { + return fmt.Errorf("cannot change actor with pending operations") + } + + b.actor = actor + + return nil +} diff --git a/pkg/automerge/internal/native/engine_helpers.go b/pkg/automerge/internal/native/engine_helpers.go new file mode 100644 index 0000000000..cc30fc2c8a --- /dev/null +++ b/pkg/automerge/internal/native/engine_helpers.go @@ -0,0 +1,701 @@ +// 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 ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sort" +) + +func (b *Engine) addPending(operation Operation) error { + if err := b.state.applyPending([]Operation{operation}); err != nil { + return err + } + + b.pending = append(b.pending, operation) + + return nil +} + +func (b *Engine) nextOperationID() OpID { + id := OpID{ + Actor: b.actor, + Counter: b.nextOp, + } + b.nextOp++ + + return id +} + +func (b *Engine) requireRoot(ctx context.Context, handle uint32) error { + if err := ctx.Err(); err != nil { + return err + } + + object, err := b.object(handle) + if err != nil { + return err + } + + if !object.IsRoot { + return fmt.Errorf("object is not the root map") + } + + return nil +} + +func (b *Engine) object(handle uint32) (ObjectID, error) { + object, ok := b.objects[handle] + if !ok { + return ObjectID{}, fmt.Errorf("invalid object handle %d", handle) + } + + return object, nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) 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 *Engine) pushObject(object ObjectID) uint32 { + handle := b.nextHandle + b.nextHandle++ + b.objects[handle] = object + + return handle +} + +func (b *Engine) syncState(handle uint32) (*nativeSyncState, error) { + state, ok := b.syncStates[handle] + if !ok { + return nil, fmt.Errorf("invalid sync state %d", handle) + } + + return state, nil +} + +func (b *Engine) rootTextObjects() map[string]Operation { + objects := make(map[string]Operation) + + for _, operation := range b.state.operations { + if operation.Object.IsRoot && + operation.Key.Property != nil && + operation.Action == ActionMakeText && + !b.state.isSuperseded(operation.ID) { + property := *operation.Key.Property + + current, ok := objects[property] + if !ok || operation.ID.Compare(current.ID) > 0 { + objects[property] = operation + } + } + } + + return objects +} + +func (b *Engine) 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) + } + + key = b.state.insertAnchorKey(object.OpID, key) + + 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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) + + // A mark boundary past the end of the text is rejected, matching the + // reference. The reference applies the begin boundary before failing on the + // out-of-range end, leaving a begin operation with no matching end; span + // computation extends such an unmatched begin to the end of the text. + _, 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 +} + +// sequenceRange resolves a UTF-16 index and delete count against the visible +// sequence using the precomputed cumulative offsets, so a splice locates its +// position by binary search rather than walking the whole sequence. offsets has +// one entry per element plus a trailing total, where offsets[i] is the width +// before element i. +func sequenceRange( + sequence []Operation, + offsets []uint32, + index uint32, + deleteCount uint32, +) (int, int, *OpID, error) { + total := offsets[len(offsets)-1] + if index > total { + return 0, 0, nil, fmt.Errorf("text index %d is out of bounds", index) + } + + // Find the element whose starting offset is the last one at or before index. + // When that offset equals index the insertion sits on the boundary before + // the element; when it is smaller the index fell inside the element (a UTF-16 + // caller addressing the middle of a surrogate pair), so advance to the + // boundary after it, matching the reference. + boundary := sort.Search(len(offsets), func(i int) bool { return offsets[i] > index }) + start := boundary - 1 + if offsets[start] < index { + start++ + } + + var previous *OpID + if start > 0 { + previousValue := sequence[start-1].ID + previous = &previousValue + } + + // A deletion that runs past the end of the sequence is clamped to the + // remaining elements rather than rejected, matching the reference, whose + // splice stops once there are no more elements to delete. end is the first + // element boundary at or past the deletion target. + target := offsets[start] + deleteCount + end := start + sort.Search(len(sequence)-start+1, func(i int) bool { + return offsets[start+i] >= target + }) + if end > len(sequence) { + end = len(sequence) + } + + 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 + } + + 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) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", fmt.Errorf("cannot generate native actor ID: %w", err) + } + + return NewActorID(value[:]) +} + +func encodeEmptyDocument() []byte { + body := []byte{0, 0, 0, 0} + hashInput := []byte{byte(ChunkDocument)} + hashInput = appendULEB(hashInput, uint64(len(body))) + hashInput = append(hashInput, body...) + hash := sha256.Sum256(hashInput) + + raw := []byte{0x85, 0x6f, 0x4a, 0x83} + raw = append(raw, hash[:4]...) + raw = append(raw, byte(ChunkDocument)) + raw = appendULEB(raw, uint64(len(body))) + + return append(raw, body...) +} + +func decodeCursor(data []byte) (OpID, byte, error) { + r := newReader(data) + + version, err := r.byte() + if err != nil || version != 1 { + return OpID{}, 0, fmt.Errorf("invalid cursor version") + } + + cursorType, err := r.byte() + if err != nil || cursorType != 3 { + return OpID{}, 0, fmt.Errorf("unsupported cursor type") + } + + actorBytes, err := decodeLengthPrefixed(r) + if err != nil { + return OpID{}, 0, fmt.Errorf("cannot decode cursor actor: %w", err) + } + + actor, err := NewActorID(actorBytes) + if err != nil { + return OpID{}, 0, err + } + + counter, err := r.uleb() + if err != nil { + return OpID{}, 0, fmt.Errorf("cannot decode cursor counter: %w", err) + } + + move, err := r.byte() + if err != nil || (move != 1 && move != 2) || r.remaining() != 0 { + return OpID{}, 0, fmt.Errorf("invalid cursor movement") + } + + return OpID{Actor: actor, Counter: counter}, move, nil +} + +func equalHashes(left, right [][32]byte) bool { + if len(left) != len(right) { + return false + } + + for i := range left { + if left[i] != right[i] { + return false + } + } + + 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/frontier_test.go b/pkg/automerge/internal/native/frontier_test.go new file mode 100644 index 0000000000..58eef36303 --- /dev/null +++ b/pkg/automerge/internal/native/frontier_test.go @@ -0,0 +1,235 @@ +// 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 ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// committedTextBackend returns a backend with a text object edited `edits` times, +// each edit its own committed change. +func committedTextBackend(t *testing.T, id byte, edits int) *Engine { + t.Helper() + + ctx := context.Background() + + backend, err := NewEngine(ctx) + require.NoError(t, err) + require.NoError(t, backend.SetActor(ctx, []byte{ + id, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, + })) + + handle, err := backend.PutText(ctx, 0, "body") + require.NoError(t, err) + + for i := range edits { + require.NoError(t, backend.SpliceText(ctx, handle, uint32(i), 0, "x")) + _, err = backend.Commit(ctx, "edit", time.Unix(int64(i), 0)) + require.NoError(t, err) + } + + return backend +} + +func snapshotFromBackend(backend *Engine) *Document { + document := &Document{} + + for hash, change := range backend.state.changes { + clone := *change + clone.Hash = new(hash) + document.Changes = append(document.Changes, clone) + } + + for head := range backend.state.heads { + document.Heads = append(document.Heads, head) + } + + return document +} + +// TestNewStateFromDocument_RebuildsInconsistentFrontier is the regression for the +// production "cannot compute changes from unknown heads" outage. A frontier that +// references a change the document does not carry must be rebuilt from the graph +// so Heads() stays consistent with changes and incremental reads keep working. +func TestNewStateFromDocument_RebuildsInconsistentFrontier(t *testing.T) { + t.Parallel() + + document := snapshotFromBackend(committedTextBackend(t, 1, 3)) + + var phantom ChangeHash + + phantom[0] = 0xAB + document.Heads = append(document.Heads, phantom) + + state, err := NewStateFromDocument(document) + require.NoError(t, err) + + for _, head := range state.Heads() { + _, ok := state.changes[head] + assert.Truef(t, ok, "head %s must exist in the change graph", head) + } + + _, ok := state.changesSince(nil) + assert.True(t, ok, "changesSince(nil) must succeed after rebuild") + + _, ok = state.changesSince(state.Heads()) + assert.True(t, ok, "changesSince(own heads) must succeed after rebuild") +} + +// TestChangesSince_DegradesToReachablePrefix is the regression for the wedge +// where one unreachable ancestor failed the whole read. A branched history has +// an intact branch and a branch whose middle change is removed; the intact +// branch must still come back as a consistent, replayable prefix while the +// broken branch is dropped, and completeness must report false. +func TestChangesSince_DegradesToReachablePrefix(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + actorBytes := func(id byte) []byte { + return []byte{id, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + } + + // Shared base commit. + base, err := NewEngine(ctx) + require.NoError(t, err) + require.NoError(t, base.SetActor(ctx, actorBytes(0x10))) + + handle, err := base.PutText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, base.SpliceText(ctx, handle, 0, 0, "a")) + _, err = base.Commit(ctx, "base", time.Unix(0, 0)) + require.NoError(t, err) + + shared, err := base.Save(ctx, true, true) + require.NoError(t, err) + + // A branch two commits deep, authored by a second actor. + deep, err := LoadEngine(ctx, shared) + require.NoError(t, err) + require.NoError(t, deep.SetActor(ctx, actorBytes(0x20))) + + deepHandle, _, err := deep.GetObject(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, deep.SpliceText(ctx, deepHandle, 1, 0, "b")) + _, err = deep.Commit(ctx, "deep-1", time.Unix(1, 0)) + require.NoError(t, err) + require.NoError(t, deep.SpliceText(ctx, deepHandle, 2, 0, "c")) + _, err = deep.Commit(ctx, "deep-2", time.Unix(2, 0)) + require.NoError(t, err) + + deepSave, err := deep.Save(ctx, true, true) + require.NoError(t, err) + + // The base adds its own branch commit, then merges the deep branch, so the + // frontier holds two heads: the base branch and the deep branch. + require.NoError(t, base.SpliceText(ctx, handle, 1, 0, "z")) + _, err = base.Commit(ctx, "base-2", time.Unix(3, 0)) + require.NoError(t, err) + _, err = base.Merge(ctx, deepSave) + require.NoError(t, err) + + all, complete := base.state.changesSince(nil) + require.True(t, complete) + require.Len(t, all, 4) + + // Remove the deep branch's first change, the ancestor of its head. + deepActor, err := NewActorID(actorBytes(0x20)) + require.NoError(t, err) + + var removed ChangeHash + + for hash, change := range base.state.changes { + if change.Actor == deepActor && change.Sequence == 1 { + removed = hash + } + } + + delete(base.state.changes, removed) + + changes, complete := base.state.changesSince(nil) + assert.False(t, complete, "the broken branch leaves the walk incomplete") + assert.Len(t, changes, 2, "the intact branch is still emitted as a prefix") + + for i, change := range changes { + require.NotNil(t, change.Hash) + assert.NotEqual(t, removed, *change.Hash) + + // Every emitted change's dependencies precede it, so the prefix replays. + for _, dependency := range change.Dependencies { + found := false + for _, earlier := range changes[:i] { + if earlier.Hash != nil && *earlier.Hash == dependency { + found = true + } + } + + assert.True(t, found, "dependency of an emitted change must precede it") + } + } + + // The engine method must not wedge: it returns the reachable prefix. + raw, hashes, err := base.ChangesSince(ctx, nil) + require.NoError(t, err) + assert.Len(t, raw, 2) + assert.Len(t, hashes, 2) +} + +// TestChangesSince_ToleratesUnknownBaseline mirrors Rust's get_changes, which +// takes have_deps by value and never errors: an unknown baseline excludes +// nothing and the full history is returned. +func TestChangesSince_ToleratesUnknownBaseline(t *testing.T) { + t.Parallel() + + backend := committedTextBackend(t, 2, 3) + + var unknown ChangeHash + + unknown[0] = 0xCD + + changes, ok := backend.state.changesSince([]ChangeHash{unknown}) + require.True(t, ok) + assert.Len(t, changes, 3) +} + +// TestChangesSince_ToleratesFrontierWithMissingChange guards the frontier walk: +// a head recorded without a retrievable change must not abort the computation. +// The reachable changes are still returned so an incremental read keeps working, +// and completeness reports false so sync knows to fall back to a full document. +func TestChangesSince_ToleratesFrontierWithMissingChange(t *testing.T) { + t.Parallel() + + backend := committedTextBackend(t, 3, 2) + + var phantom ChangeHash + + phantom[0] = 0xEF + backend.state.heads[phantom] = struct{}{} + + changes, complete := backend.state.changesSince(nil) + assert.False(t, complete, "an unretrievable head leaves the walk incomplete") + assert.Len(t, changes, 2, "the reachable changes are still returned") +} diff --git a/pkg/automerge/internal/native/fuzz_test.go b/pkg/automerge/internal/native/fuzz_test.go new file mode 100644 index 0000000000..f88839d1aa --- /dev/null +++ b/pkg/automerge/internal/native/fuzz_test.go @@ -0,0 +1,116 @@ +// 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 ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/require" +) + +const ( + officialChangeFixture = "hW9Kg5nDjoUBzgEAEAECAwQFBgcICQoLDA0ODxABAYDiz6oGF29mZmljaWFsIHNjYWxhciBmaXh0dXJlAAoBBAIHEQYTBxU4NAJCCFYQVxtwAgALBAAACwILfgwNAAx/AAACAAt8AAx0AHUDbmlsAm5vA3llcwR1aW50A2ludAVmbG9hdAR0ZXh0BWJ5dGVzBHdoZW4FY291bnQEbGlzdAAECwQKAX8CAgQCAXYAAQITFIUBVjdpGAMAAhYqeQAAAAAAAPg/aGVsbG8A/wf70JX/vDEJYWIPAA==" + officialDocumentFixture = "hW9Kg3tNcOoAogIBEAECAwQFBgcICQoLDA0ODxABmcOOhfOq6K9fyRtQMpEkw5nRGiPrg0/hSLI3KA5LqKcHAQIDAhMCIwY1GUACVgIMAQQCBxEGEwcVOCECIw80AkIKVhJXG4ABAn8AfwF/D3+A4s+qBn8Xb2ZmaWNpYWwgc2NhbGFyIGZpeHR1cmV/AH8HAAsEAAALAgt+DA0ADH8AAAIAC3wADHQAdQVieXRlcwVjb3VudAVmbG9hdANpbnQEbGlzdANuaWwCbm8EdGV4dAR1aW50BHdoZW4DeWVzAAQPAHQIAnx/BnYBBX0FegkDAQsEBAF/AgYBAgQCAXw3GIUBFAIAewFWE2kCAgACFgD/BwkAAAAAAAD4P3loZWxsbyr70JX/vDFhYg8AAA==" +) + +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) + document, err := base64.StdEncoding.DecodeString(officialDocumentFixture) + require.NoError(f, err) + + f.Add(change) + 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() + } + + _, _ = 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/history.go b/pkg/automerge/internal/native/history.go new file mode 100644 index 0000000000..efe08562bb --- /dev/null +++ b/pkg/automerge/internal/native/history.go @@ -0,0 +1,371 @@ +// 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 ( + "bytes" + "context" + "fmt" + "sort" +) + +func (b *Engine) Heads(ctx context.Context) ([][32]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + heads := b.state.Heads() + + result := make([][32]byte, len(heads)) + for i := range heads { + result[i] = [32]byte(heads[i]) + } + + return result, nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) ChangesSince( + ctx context.Context, + heads [][32]byte, +) ([][]byte, [][32]byte, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + knownHeads := make([]ChangeHash, len(heads)) + for i, head := range heads { + knownHeads[i] = ChangeHash(head) + } + + // A change in the frontier's ancestry may occasionally be unreachable, for + // example after a merge that rebuilt the graph. changesSince then returns the + // consistent, replayable prefix it can produce rather than nothing: reporting + // every change that can be emitted keeps collaboration alive, where failing + // the whole read would wedge the document on every request. A change that is + // dropped has no bytes to return in any case. + changes, _ := b.state.changesSince(knownHeads) + + raw := make([][]byte, len(changes)) + + hashes := make([][32]byte, len(changes)) + for i, change := range changes { + if change.Hash == nil { + return nil, nil, fmt.Errorf("change %d has no hash", i) + } + + raw[i] = append([]byte(nil), change.Raw...) + hashes[i] = [32]byte(*change.Hash) + } + + return raw, hashes, nil +} + +func (b *Engine) ApplyChanges( + ctx context.Context, + changes [][]byte, +) error { + for i, change := range changes { + if _, err := b.Merge(ctx, change); err != nil { + return fmt.Errorf("cannot apply native change %d: %w", i, err) + } + } + + return nil +} + +func (b *Engine) Merge(ctx context.Context, data []byte) ([][32]byte, error) { + if err := ctx.Err(); err != nil { + 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) + } + + if err != nil { + return nil, err + } + + // A merge may apply nothing when every change is already present, but bumping + // unconditionally only risks an extra rebuild on the next save, never a stale + // one, and it keeps every apply path covered by a single line. + b.revision++ + + if len(b.state.Heads()) == 0 && len(b.pending) == 0 { + state, err := NewStateFromDocument(document) + if err != nil { + return nil, fmt.Errorf("cannot initialize merged native state: %w", err) + } + + 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 *Engine) 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 *Engine) 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 nil +} + +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 nil +} + +func (b *Engine) applyMergedChanges(changes []Change) error { + for i := range changes { + change := &changes[i] + if change.Hash == nil || b.state.hasChange(*change.Hash) { + continue + } + + if _, queued := b.queuedChanges[*change.Hash]; queued { + continue + } + + if len(change.Raw) == 0 { + return fmt.Errorf( + "cannot preserve merged change %s: original bytes are unavailable", + change.Hash, + ) + } + + if len(b.queuedChanges) >= maxQueuedChanges || + b.queuedBytes+len(change.Raw) > maxQueuedChangeBytes { + return fmt.Errorf("merged change queue exceeds its resource limit") + } + + clone := *change + clone.Raw = append([]byte(nil), change.Raw...) + b.queuedChanges[*change.Hash] = &clone + b.queuedBytes += len(clone.Raw) + } + + for len(b.queuedChanges) > 0 { + progressed := false + + for hash, change := range b.queuedChanges { + if !b.state.hasDependencies(change) { + continue + } + + if err := b.state.ApplyChange(change); err != nil { + return fmt.Errorf("cannot apply merged native change: %w", err) + } + + b.appended = append( + b.appended, + append([]byte(nil), change.Raw...), + ) + b.queuedBytes -= len(change.Raw) + delete(b.queuedChanges, hash) + + progressed = true + } + + if !progressed { + break + } + } + + return nil +} diff --git a/pkg/automerge/internal/native/hydrate_state.go b/pkg/automerge/internal/native/hydrate_state.go new file mode 100644 index 0000000000..4484cbc2dc --- /dev/null +++ b/pkg/automerge/internal/native/hydrate_state.go @@ -0,0 +1,168 @@ +// 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 ( + "fmt" + "sort" + "strings" +) + +func (s *State) mapValue( + object OpID, + visited map[OpID]struct{}, +) (map[string]any, error) { + if _, ok := visited[object]; ok { + return nil, fmt.Errorf("object cycle detected") + } + + visited[object] = struct{}{} + defer delete(visited, object) + + properties := make(map[string][]Operation) + + for _, operation := range s.operations { + if operation.Object.IsRoot || + operation.Object.OpID != object || + operation.Key.Property == nil || + s.isSuperseded(operation.ID) { + continue + } + + property := *operation.Key.Property + properties[property] = append(properties[property], operation) + } + + result := make(map[string]any, len(properties)) + for property, operations := range properties { + sort.Slice(operations, func(i, j int) bool { + return operations[i].ID.Compare(operations[j].ID) > 0 + }) + + operation := operations[0] + switch operation.Action { + case ActionMakeMap: + value, err := s.mapValue(operation.ID, visited) + if err != nil { + return nil, err + } + + result[property] = value + case ActionMakeList: + value, err := s.listValue(operation.ID, visited) + if err != nil { + return nil, err + } + + result[property] = value + case ActionMakeText: + var value strings.Builder + + for _, element := range s.sequence(operation.ID) { + if element.Value != nil && element.Value.Type == ScalarString { + value.WriteString(element.Value.String) + } + } + + result[property] = value.String() + case ActionSet: + result[property] = scalarMaterializedValue(operation.Value) + } + } + + return result, nil +} + +func (s *State) listValue( + object OpID, + visited map[OpID]struct{}, +) ([]any, error) { + if _, ok := visited[object]; ok { + return nil, fmt.Errorf("object cycle detected") + } + + visited[object] = struct{}{} + defer delete(visited, object) + + elements := s.sequenceElements(object) + result := make([]any, 0, len(elements)) + + for _, element := range elements { + switch element.Action { + case ActionMakeMap: + value, err := s.mapValue(element.ID, visited) + if err != nil { + return nil, err + } + + result = append(result, value) + case ActionMakeList: + value, err := s.listValue(element.ID, visited) + if err != nil { + return nil, err + } + + result = append(result, value) + case ActionMakeText: + var value strings.Builder + + for _, textElement := range s.sequence(element.ID) { + if textElement.Value != nil && + textElement.Value.Type == ScalarString { + value.WriteString(textElement.Value.String) + } + } + + result = append(result, value.String()) + case ActionSet: + result = append(result, scalarMaterializedValue(element.Value)) + } + } + + return result, nil +} + +func scalarMaterializedValue(value *Scalar) any { + if value == nil { + return nil + } + + switch value.Type { + case ScalarNull: + return nil + case ScalarFalse: + return false + case ScalarTrue: + return true + case ScalarUint: + return value.Uint + case ScalarInt, ScalarCounter, ScalarTimestamp: + return value.Int + case ScalarFloat64: + return value.Float + case ScalarString: + return value.String + case ScalarBytes: + return append([]byte(nil), value.Bytes...) + default: + return append([]byte(nil), value.Raw...) + } +} diff --git a/pkg/automerge/internal/native/incremental_sync_test.go b/pkg/automerge/internal/native/incremental_sync_test.go new file mode 100644 index 0000000000..fa224da8b0 --- /dev/null +++ b/pkg/automerge/internal/native/incremental_sync_test.go @@ -0,0 +1,85 @@ +// 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 ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackendSync_SendsOnlyChangesSinceRemoteHeads(t *testing.T) { + t.Parallel() + + ctx := context.Background() + backend, err := NewEngine(ctx) + require.NoError(t, err) + require.NoError(t, backend.SetActor(ctx, []byte{1})) + text, err := backend.PutText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, backend.SpliceText(ctx, text, 0, 0, "A")) + _, err = backend.Commit(ctx, "first", time.Unix(1, 0)) + require.NoError(t, err) + + syncHandle, err := backend.NewSyncState(ctx) + require.NoError(t, err) + firstMessage, ok, err := backend.GenerateSyncMessage(ctx, syncHandle) + require.NoError(t, err) + assert.True(t, ok) + + first, err := ParseSyncMessage(firstMessage) + require.NoError(t, err) + require.Len(t, first.Changes, 1) + firstDocument, err := Decode(first.Changes[0]) + require.NoError(t, err) + assert.Equal(t, []ChunkType{ChunkChange}, firstDocument.ChunkTypes) + + ackMessage := SyncMessage{ + Version: SyncMessageVersion2, + Heads: first.Heads, + } + ack, err := ackMessage.Encode() + require.NoError(t, err) + require.NoError(t, backend.ReceiveSyncMessage(ctx, syncHandle, ack)) + + require.NoError(t, backend.SpliceText(ctx, text, 1, 0, "B")) + _, err = backend.Commit(ctx, "second", time.Unix(2, 0)) + require.NoError(t, err) + secondMessage, ok, err := backend.GenerateSyncMessage(ctx, syncHandle) + require.NoError(t, err) + assert.True(t, ok) + + second, err := ParseSyncMessage(secondMessage) + require.NoError(t, err) + require.Len(t, second.Changes, 1) + + secondDocument, err := DecodePartial(second.Changes[0]) + require.NoError(t, err) + require.Len(t, secondDocument.Changes, 1) + assert.Equal(t, "second", secondDocument.Changes[0].Message) + + fullDocument, err := backend.Save(ctx, true, true) + require.NoError(t, err) + assert.Less(t, len(secondMessage), len(fullDocument)) +} diff --git a/pkg/automerge/internal/native/merge_test.go b/pkg/automerge/internal/native/merge_test.go new file mode 100644 index 0000000000..a5f2ff755d --- /dev/null +++ b/pkg/automerge/internal/native/merge_test.go @@ -0,0 +1,89 @@ +// 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 ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBackendMerge_AppliesReversedDependentChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := NewEngine(ctx) + require.NoError(t, err) + require.NoError(t, base.SetActor(ctx, []byte{1})) + text, err := base.PutText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, base.SpliceText(ctx, text, 0, 0, "A")) + _, err = base.Commit(ctx, "base", time.Unix(1, 0)) + require.NoError(t, err) + baseData, err := base.Save(ctx, true, true) + require.NoError(t, err) + + source, err := LoadEngine(ctx, baseData) + require.NoError(t, err) + require.NoError(t, source.SetActor(ctx, []byte{2})) + sourceText, err := source.GetText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, source.SpliceText(ctx, sourceText, 1, 0, "B")) + _, err = source.Commit(ctx, "parent", time.Unix(2, 0)) + require.NoError(t, err) + + parent := append([]byte(nil), source.appended[len(source.appended)-1]...) + + require.NoError(t, source.SpliceText(ctx, sourceText, 2, 0, "C")) + _, err = source.Commit(ctx, "child", time.Unix(3, 0)) + require.NoError(t, err) + + child := append([]byte(nil), source.appended[len(source.appended)-1]...) + + target, err := LoadEngine(ctx, baseData) + require.NoError(t, err) + _, err = target.Merge(ctx, append(child, parent...)) + require.NoError(t, err) + targetText, err := target.GetText(ctx, 0, "body") + require.NoError(t, err) + value, err := target.Text(ctx, targetText) + require.NoError(t, err) + assert.Equal(t, "ABC", value) + + separate, err := LoadEngine(ctx, baseData) + require.NoError(t, err) + _, err = separate.Merge(ctx, child) + require.NoError(t, err) + assert.Len(t, separate.queuedChanges, 1) + + _, err = separate.Merge(ctx, parent) + require.NoError(t, err) + assert.Empty(t, separate.queuedChanges) + + separateText, err := separate.GetText(ctx, 0, "body") + require.NoError(t, err) + separateValue, err := separate.Text(ctx, separateText) + require.NoError(t, err) + assert.Equal(t, "ABC", separateValue) +} diff --git a/pkg/automerge/internal/native/model.go b/pkg/automerge/internal/native/model.go new file mode 100644 index 0000000000..dd72604218 --- /dev/null +++ b/pkg/automerge/internal/native/model.go @@ -0,0 +1,70 @@ +// 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 "go.probo.inc/probo/pkg/automerge/internal/opset" + +type ( + ActorID = opset.ActorID + ChangeHash = opset.ChangeHash + OpID = opset.OpID + ObjectID = opset.ObjectID + Key = opset.Key + Action = opset.Action + ScalarType = opset.ScalarType + Scalar = opset.Scalar + Operation = opset.Operation + Change = opset.Change + ChunkType = opset.ChunkType + RawColumn = opset.RawColumn + Document = opset.Document +) + +const ( + ActionMakeMap = opset.ActionMakeMap + ActionSet = opset.ActionSet + ActionMakeList = opset.ActionMakeList + ActionDelete = opset.ActionDelete + ActionMakeText = opset.ActionMakeText + ActionIncrement = opset.ActionIncrement + ActionMakeTable = opset.ActionMakeTable + ActionMark = opset.ActionMark + + ScalarNull = opset.ScalarNull + ScalarFalse = opset.ScalarFalse + ScalarTrue = opset.ScalarTrue + ScalarUint = opset.ScalarUint + ScalarInt = opset.ScalarInt + ScalarFloat64 = opset.ScalarFloat64 + ScalarString = opset.ScalarString + ScalarBytes = opset.ScalarBytes + ScalarCounter = opset.ScalarCounter + ScalarTimestamp = opset.ScalarTimestamp + + ChunkDocument = opset.ChunkDocument + ChunkChange = opset.ChunkChange + ChunkCompressedChange = opset.ChunkCompressedChange +) + +var ( + NewActorID = opset.NewActorID + RootObject = opset.RootObject +) diff --git a/pkg/automerge/internal/native/object.go b/pkg/automerge/internal/native/object.go new file mode 100644 index 0000000000..ba58f08ea8 --- /dev/null +++ b/pkg/automerge/internal/native/object.go @@ -0,0 +1,724 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "slices" +) + +func (b *Engine) PutString( + ctx context.Context, + object uint32, + key string, + value string, +) error { + if err := b.requireRoot(ctx, object); err != nil { + 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{ + ID: b.nextOperationID(), + Object: RootObject(), + Key: Key{Property: &property}, + Action: ActionSet, + Value: &Scalar{Type: ScalarString, String: value}, + } + for _, predecessor := range b.state.visibleMapOperations(key) { + operation.Predecessors = append(operation.Predecessors, predecessor.ID) + } + + return b.addPending(operation) +} + +func (b *Engine) GetString( + ctx context.Context, + object uint32, + key string, +) (string, error) { + if err := b.requireRoot(ctx, object); err != nil { + return "", 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 operation.Value.String, nil +} + +func (b *Engine) PutScalar( + ctx context.Context, + object uint32, + key string, + encoded []byte, +) error { + if err := ctx.Err(); err != nil { + return err + } + + objectID, err := b.mapObject(object) + if err != nil { + return err + } + + value, err := decodeScalarWire(encoded) + if err != nil { + return err + } + + property := key + + if existing, ok := b.state.visibleMapObjectValue(objectID, key); ok { + existingValue, scalar := b.state.scalarValue(existing) + if scalar && scalarValuesEqual(existingValue, value) { + // Assigning the value the winning operation already holds changes + // nothing, so an unconflicted key records no operation. A conflicted + // key still has to collapse: the reference deletes the losing + // siblings and keeps the winner rather than writing the value again. + losing := make([]OpID, 0) + + for _, operation := range b.state.visibleMapObjectOperations(objectID, key) { + if operation.ID != existing.ID { + losing = append(losing, operation.ID) + } + } + + if len(losing) == 0 { + return nil + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Property: &property}, + Action: ActionDelete, + Predecessors: losing, + }) + } + } + + 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 b.addPending(operation) +} + +func (b *Engine) GetScalar( + ctx context.Context, + object uint32, + key string, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + objectID, err := b.mapObject(object) + if err != nil { + return nil, err + } + + operation, ok := b.state.visibleMapObjectValue(objectID, key) + if !ok { + return nil, fmt.Errorf("scalar property %q does not exist", key) + } + + 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 *Engine) GetScalarAtHeads( + ctx context.Context, + object uint32, + key string, + heads [][32]byte, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + objectID, err := b.mapObject(object) + if err != nil { + return nil, err + } + + historical, ok := b.state.at(nativeHashes(heads)) + if !ok { + return nil, fmt.Errorf("historical heads are unknown") + } + + operation, ok := historical.visibleMapObjectValue(objectID, key) + if !ok { + return nil, fmt.Errorf("scalar property %q does not exist", key) + } + + value, ok := historical.scalarValue(operation) + if !ok { + return nil, fmt.Errorf("map value %q is not a scalar", key) + } + + return encodeScalarWire(value) +} + +func (b *Engine) GetAllScalars( + ctx context.Context, + object uint32, + key string, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + objectID, err := b.mapObject(object) + if err != nil { + return nil, err + } + + var values []json.RawMessage + + 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 + } + + // Assigning the value the winning operation already holds changes nothing, + // so an unconflicted element records no operation. A conflicted element + // still has to collapse: the reference deletes the losing siblings and keeps + // the winner rather than writing the same value again. + if existingValue, scalar := b.state.scalarValue(target.Operation); scalar && + scalarValuesEqual(existingValue, value) { + losing := make([]OpID, 0) + + for _, operation := range b.state.visibleSequenceElementOperations(target.Element) { + if operation.ID != target.Operation.ID { + losing = append(losing, operation.ID) + } + } + + if len(losing) == 0 { + return nil + } + + return b.addPending(Operation{ + ID: b.nextOperationID(), + Object: objectID, + Key: Key{Element: new(target.Element)}, + Action: ActionDelete, + Predecessors: losing, + }) + } + + 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 := slices.ContainsFunc(visible, isCounterOperation) + + 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 *Engine) 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 := slices.ContainsFunc(visible, isCounterOperation) + + 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 *Engine) 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 *Engine) 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 +} diff --git a/pkg/automerge/internal/native/patch.go b/pkg/automerge/internal/native/patch.go new file mode 100644 index 0000000000..4823af0f04 --- /dev/null +++ b/pkg/automerge/internal/native/patch.go @@ -0,0 +1,1024 @@ +// 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 ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" +) + +func (b *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) incrementalPatches( + beforeHeads [][32]byte, + afterHeads [][32]byte, +) ([]patchOut, error) { + return b.diffPatches(beforeHeads, afterHeads, true) +} + +func (b *Engine) 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 + } + + if _, ok := source.operations[id]; ok { + continue + } + + begins = append(begins, operation) + } + + sort.Slice(begins, func(i, j int) bool { + return begins[i].ID.Compare(begins[j].ID) < 0 + }) + + out := make([]markPatchOut, 0, len(begins)) + + 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), + }) + } + + 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 + } + + runs := make([]textRun, 0) + + 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 !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++ + } + } + + if haveRun { + runs = append(runs, textRun{index: runStart, text: builder.String(), marks: runMarks}) + } + + return runs, nil +} + +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{}{} + } + + for _, key := range target.mapKeys(object) { + keys[key] = struct{}{} + } + + ordered := make([]string, 0, len(keys)) + for key := range keys { + ordered = append(ordered, key) + } + + 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}, + }) + } + } + + 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{}{} + } + + targetElements := make(map[OpID]struct{}, len(targetValues)) + for _, value := range targetValues { + targetElements[value.Element] = struct{}{} + } + + // 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 + } + + patches := make([]patchOut, 0) + position := uint64(0) + i, j := 0, 0 + + 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 +} diff --git a/pkg/automerge/internal/native/rich_text.go b/pkg/automerge/internal/native/rich_text.go new file mode 100644 index 0000000000..861617dadc --- /dev/null +++ b/pkg/automerge/internal/native/rich_text.go @@ -0,0 +1,1249 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +func (b *Engine) 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 *Engine) 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 *Engine) 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) + offsets := b.state.sequenceOffsets(object.OpID, sequence) + + start, end, previous, err := sequenceRange(sequence, offsets, index, uint32(deleteCount)) + if err != nil { + return err + } + + // The reference resolves the insertion anchor and inserts before deleting, + // so replacement text is positioned against the pre-deletion sequence. That + // ordering decides whether text replacing a marked run sits inside or + // outside an expanding mark, so it must be preserved here. + targets := make([]Operation, end-start) + copy(targets, sequence[start:end]) + + for offset, character := range []rune(value) { + key := Key{IsHead: previous == nil} + if previous != nil { + key.Element = new(*previous) + } + + // Only the first character resolves its anchor against neighbouring + // mark boundaries; the rest chain onto the character before them. + if offset == 0 { + key = b.state.insertAnchorKey(object.OpID, key) + } + + 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) + } + + for _, target := range targets { + 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 + } + } + + 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 *Engine) 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, + engine: 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 *Engine) 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 + engine *Engine + 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 := range length { + 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.engine.JoinBlock(h.ctx, h.handle, uint32(h.idx)) + + continue + } + + h.err = h.engine.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.engine.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.engine.SplitBlock(h.ctx, h.handle, uint32(h.idx)) + if err != nil { + h.err = err + + return + } + + if err := h.engine.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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 + } + + expandBefore, expandAfter, err := markExpansion(expand) + if err != nil { + return err + } + + // Mark boundaries are inserted like any other element, so they resolve their + // anchors through the same query. The end anchor is resolved after the begin + // operation exists, matching the reference, because the begin can itself + // offer a position that the end must take into account. + startKey, err := b.textMarkKey(object, start) + if err != nil { + return err + } + + begin := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: b.state.insertAnchorKey(object.OpID, startKey), + Insert: true, + Action: ActionMark, + Value: &value, + MarkExpand: &expandBefore, + MarkName: &name, + } + if err := b.addPending(begin); err != nil { + return err + } + + endKey, err := b.textMarkKey(object, end) + if err != nil { + return err + } + + endOperation := Operation{ + ID: b.nextOperationID(), + Object: object, + Key: b.state.insertAnchorKey(object.OpID, endKey), + Insert: true, + Action: ActionMark, + Value: &Scalar{Type: ScalarNull}, + MarkExpand: &expandAfter, + } + + return b.addPending(endOperation) +} + +func (b *Engine) 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) + } + + // A block marker shares the unified rich-text sequence with text, so it must + // resolve its anchor against neighbouring mark boundaries the same way a text + // insertion does. Without this, a block inserted next to a mark boundary lands + // on the wrong side of it, which then misplaces later insertions and makes the + // marks they should or should not carry diverge from the reference. + key = b.state.insertAnchorKey(object.OpID, key) + + 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) TextCursor( + ctx context.Context, + handle uint32, + index uint32, +) ([]byte, error) { + return b.TextCursorMoving(ctx, handle, index, false) +} + +func (b *Engine) 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 *Engine) 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) + + 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 *Engine) 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 *Engine) 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 + } +} diff --git a/pkg/automerge/internal/native/rich_text_state.go b/pkg/automerge/internal/native/rich_text_state.go new file mode 100644 index 0000000000..7ac665eb08 --- /dev/null +++ b/pkg/automerge/internal/native/rich_text_state.go @@ -0,0 +1,622 @@ +// 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 ( + "fmt" + "reflect" + "sort" +) + +func (s *State) RichTextSpans(object OpID) ([]RichSpan, error) { + elements := s.sequenceElements(object) + marks := s.richTextMarks(object, elements) + spans := make([]RichSpan, 0) + + for i, operation := range elements { + switch operation.Action { + case ActionMakeMap: + value, err := s.mapValue(operation.ID, make(map[OpID]struct{})) + if err != nil { + return nil, fmt.Errorf("cannot hydrate block %v: %w", operation.ID, err) + } + + spans = append(spans, RichSpan{Type: "block", Value: value}) + case ActionSet: + if operation.Value == nil || operation.Value.Type != ScalarString { + continue + } + + activeMarks := make(map[string]any) + + for _, mark := range marks { + if i >= mark.start && i < mark.end { + if mark.value == nil { + delete(activeMarks, mark.name) + } else { + activeMarks[mark.name] = mark.value + } + } + } + + if len(activeMarks) == 0 { + activeMarks = nil + } + + if len(spans) > 0 && + spans[len(spans)-1].Type == "text" && + reflect.DeepEqual(spans[len(spans)-1].Marks, activeMarks) { + spans[len(spans)-1].Value = spans[len(spans)-1].Value.(string) + + operation.Value.String + } else { + spans = append( + spans, + RichSpan{ + Type: "text", + Value: operation.Value.String, + Marks: activeMarks, + }, + ) + } + } + } + + return spans, nil +} + +type richTextMark struct { + start int + end int + name string + value any + scalar *Scalar + // id is the mark's begin operation, which orders precedence: a later mark + // (an unmark, or a new value) overrides an earlier one where they overlap. + id OpID +} + +// 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 +} + +// insertAnchorKey adjusts an insertion anchor so a new element lands on the +// correct side of the mark boundaries that follow it, mirroring the reference's +// insert query. Scanning forward from the anchor, an expanding mark begin and a +// non-expanding mark end each offer a position after themselves, so the new +// element joins the expanding range. Reaching the end of a mark whose begin +// offered a position withdraws that offer, because a begin/end pair with no +// visible content between them must not capture the insertion. The scan stops at +// the first visible element; tombstones are stepped over. +func (s *State) insertAnchorKey(object OpID, base Key) Key { + order := s.insertOrder(object) + + start := 0 + + if !base.IsHead { + if base.Element == nil { + return base + } + + position, ok := s.insertOrderPositions(object)[*base.Element] + if !ok { + return base + } + + start = position + 1 + } + + type candidate struct { + key Key + id *OpID + } + + candidates := []candidate{{key: base}} + + for i := start; i < len(order); i++ { + operation, ok := s.operations[order[i]] + if !ok { + continue + } + + if operation.Action == ActionMark { + expand := operation.MarkExpand != nil && *operation.MarkExpand + isEnd := operation.MarkName == nil + withdrawn := false + + if isEnd { + begin := OpID{Actor: operation.ID.Actor, Counter: operation.ID.Counter - 1} + + for index := range candidates { + if candidates[index].id != nil && *candidates[index].id == begin { + candidates = candidates[:index] + withdrawn = true + + break + } + } + } + + if !withdrawn && ((!isEnd && expand) || (isEnd && !expand)) { + candidates = append(candidates, candidate{ + key: Key{Element: new(operation.ID)}, + id: new(operation.ID), + }) + } + + continue + } + + if !s.isSuperseded(operation.ID) && len(candidates) > 0 { + break + } + } + + if len(candidates) == 0 { + return base + } + + return candidates[len(candidates)-1].key +} + +// richTextMarks computes the active mark ranges of a text object by walking the +// sequence order and running a mark state machine, mirroring the reference. A +// mark begin opens a range at the current visible index and its matching end +// closes it. Because mark operations hold positions in the sequence, text +// inserted at an expanding boundary sits inside the range and keeps the mark +// even after the originally marked content is deleted. +func (s *State) richTextMarks(object OpID, elements []Operation) []richTextMark { + order := s.insertOrder(object) + + type openMark struct { + start int + operation Operation + } + + open := make(map[OpID]openMark) + marks := make([]richTextMark, 0) + elementIndex := make(map[OpID]int) + index := 0 + + closeMark := func(begin openMark, end int) { + if end <= begin.start || begin.operation.MarkName == nil { + return + } + + marks = append(marks, richTextMark{ + start: begin.start, + end: end, + name: *begin.operation.MarkName, + value: scalarMaterializedValue(begin.operation.Value), + scalar: begin.operation.Value, + id: begin.operation.ID, + }) + } + + for _, id := range order { + operation, ok := s.operations[id] + if !ok || s.isSuperseded(id) { + continue + } + + if operation.Action != ActionMark { + elementIndex[id] = index + index++ + + continue + } + + if operation.MarkName != nil { + open[operation.ID] = openMark{start: index, operation: operation} + + continue + } + + begin := OpID{Actor: operation.ID.Actor, Counter: operation.ID.Counter - 1} + if opened, ok := open[begin]; ok { + delete(open, begin) + closeMark(opened, index) + } + } + + // A begin whose matching end operation was never created extends to the end + // of the text. This happens when a mark was applied with an out-of-range end + // boundary: the reference records the begin and then fails on the end, so the + // begin dangles. A begin whose end operation exists but was simply visited + // first (a zero-length mark, where begin and end share an anchor and sibling + // insertions are ordered by descending operation ID) covers nothing. + remaining := make([]openMark, 0, len(open)) + + for _, opened := range open { + endID := OpID{Actor: opened.operation.ID.Actor, Counter: opened.operation.ID.Counter + 1} + + // The end operation exists only when the following operation is actually + // a mark end. A begin whose end insert failed leaves that counter free + // for a later operation (a delete, say), so checking the action avoids + // mistaking such an operation for the missing end. + if end, ok := s.operations[endID]; ok && + end.Action == ActionMark && end.MarkName == nil { + continue + } + + remaining = append(remaining, opened) + } + + sort.Slice(remaining, func(i, j int) bool { + return remaining[i].operation.ID.Compare(remaining[j].operation.ID) < 0 + }) + + for _, opened := range remaining { + // A dangling begin that expands leftward (expand "before" or "both") + // covers text back to its own anchor rather than only from where it sorts + // in the RGA order. The begin sorts after same-anchor insertions by + // descending operation ID, so its walk index lands past text it should + // cover; the reference instead starts the mark at the position just after + // the begin's anchor element (or at the document start for a head anchor). + if opened.operation.MarkExpand != nil && *opened.operation.MarkExpand { + opened.start = danglingBeginStart(opened.operation.Key, elementIndex, opened.start) + } + + closeMark(opened, index) + } + + // Precedence follows creation order, so a later unmark or replacement value + // wins over an earlier mark where the two overlap. + sort.SliceStable(marks, func(i, j int) bool { + return marks[i].id.Compare(marks[j].id) < 0 + }) + + _ = elements + + return marks +} + +// danglingBeginStart returns the visible index a leftward-expanding dangling +// begin should start from: the document start for a head anchor, the position +// immediately after the anchor element otherwise, and the walk index as a +// fallback when the anchor is no longer visible. +func danglingBeginStart(anchor Key, elementIndex map[OpID]int, fallback int) int { + if anchor.IsHead { + return 0 + } + + if anchor.Element != nil { + if position, ok := elementIndex[*anchor.Element]; ok { + return position + 1 + } + } + + return fallback +} + +// 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 + } +} diff --git a/pkg/automerge/internal/native/sequence_state.go b/pkg/automerge/internal/native/sequence_state.go new file mode 100644 index 0000000000..9a423086c1 --- /dev/null +++ b/pkg/automerge/internal/native/sequence_state.go @@ -0,0 +1,442 @@ +// 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 ( + "sort" +) + +func (s *State) sequence(object OpID) []Operation { + if cached, ok := s.sequenceCache[object]; ok { + return cached + } + + operations := s.sequenceElements(object) + + result := operations[:0] + for _, operation := range operations { + if operation.Action == ActionSet { + result = append(result, 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 { + if cached, ok := s.sequenceElementsCache[object]; ok { + return cached + } + + order := s.insertOrder(object) + + operations := make([]Operation, 0, len(order)) + + for _, id := range order { + if s.isSuperseded(id) { + continue + } + + if operation, ok := s.operations[id]; ok && operation.Action != ActionMark { + operations = append(operations, operation) + } + } + + s.sequenceElementsCache[object] = operations + + return operations +} + +func (s *State) sequenceAll(object OpID) []Operation { + order := s.insertOrder(object) + + operations := make([]Operation, 0, len(order)) + + for _, id := range order { + if operation, ok := s.operations[id]; ok && operation.Action != ActionMark { + operations = append(operations, operation) + } + } + + return operations +} + +// insertOrder returns the RGA-ordered insertion operation IDs for a sequence +// object (including tombstones, excluding marks), using the incremental cache +// when present and rebuilding from the operation set otherwise. +func (s *State) insertOrder(object OpID) []OpID { + if cached, ok := s.insertOrderCache[object]; ok { + return cached + } + + children := make(map[OpID][]Operation) + + var head []Operation + + for _, operation := range s.operations { + // Mark begin and end operations occupy positions in the sequence so + // insertions can anchor relative to them; element views filter them out. + if operation.Object.IsRoot || + operation.Object.OpID != object || + !operation.Insert { + continue + } + + if operation.Key.IsHead { + head = append(head, operation) + } else if operation.Key.Element != nil { + children[*operation.Key.Element] = append( + children[*operation.Key.Element], + operation, + ) + } + } + + operations := make([]Operation, 0) + s.appendSequence( + &operations, + head, + children, + make(map[OpID]struct{}), + true, + ) + + order := make([]OpID, len(operations)) + for i, operation := range operations { + order[i] = operation.ID + } + + s.insertOrderCache[object] = order + + return order +} + +// spliceInsertOrder inserts a locally created insertion operation into the +// cached RGA order. Local operations always carry the current maximum operation +// ID, so they sort ahead of every existing sibling: a head-anchored insertion +// goes to the front and an element-anchored one goes immediately after its +// anchor. If the object's order has not been cached yet the splice is skipped +// and the order is rebuilt on the next read. +func (s *State) spliceInsertOrder(operation Operation) { + if !operation.Insert || operation.Object.IsRoot { + return + } + + object := operation.Object.OpID + + order, ok := s.insertOrderCache[object] + if !ok { + return + } + + if operation.Key.IsHead { + s.insertOrderCache[object] = append([]OpID{operation.ID}, order...) + // A prepend shifts every position, so the index is rebuilt on demand. + delete(s.insertOrderPositionCache, object) + + return + } + + if operation.Key.Element == nil { + delete(s.insertOrderCache, object) + delete(s.insertOrderPositionCache, object) + + return + } + + anchor := *operation.Key.Element + + if len(order) > 0 && order[len(order)-1] == anchor { + s.insertOrderCache[object] = append(order, operation.ID) + + // Appending at the end keeps every existing index, so extend the position + // index in step to keep sequential typing constant time. + if positions, ok := s.insertOrderPositionCache[object]; ok && + len(positions) == len(order) { + positions[operation.ID] = len(order) + } + + return + } + + for i, id := range order { + if id != anchor { + continue + } + + position := i + 1 + updated := make([]OpID, 0, len(order)+1) + updated = append(updated, order[:position]...) + updated = append(updated, operation.ID) + updated = append(updated, order[position:]...) + s.insertOrderCache[object] = updated + // An insertion in the middle shifts later positions; rebuild on demand. + delete(s.insertOrderPositionCache, object) + + return + } + + // The anchor is not present in the cached order; rebuild lazily. + delete(s.insertOrderCache, object) + delete(s.insertOrderPositionCache, object) +} + +func (s *State) sequenceValues(object OpID) []sequenceValue { + if cached, ok := s.sequenceValuesCache[object]; ok { + return cached + } + + insertions := s.sequenceAll(object) + values := make([]sequenceValue, 0, len(insertions)) + + // Collect the winning replacement value per element in a single pass. Doing + // this per insertion would rescan every operation for every element, which + // is quadratic in the size of the document. + winners := s.elementValueWinners() + + for _, insertion := range insertions { + var ( + value Operation + found bool + ) + + if !s.isSuperseded(insertion.ID) { + value = insertion + found = true + } + + if replacement, ok := winners[insertion.ID]; ok { + if !found || replacement.ID.Compare(value.ID) > 0 { + value = replacement + found = true + } + } + + if found { + values = append( + values, + sequenceValue{ + Element: insertion.ID, + Operation: value, + }, + ) + } + } + + s.sequenceValuesCache[object] = values + + return values +} + +// updateSequenceValues keeps the materialized sequence values coherent after an +// operation is applied. Appending a brand new element at the end of the +// sequence extends the cached slice, which keeps sequential editing linear; +// anything else (a replacement, a deletion, an insertion in the middle) can +// change which values win, so the entry is dropped and rebuilt on demand. +func (s *State) updateSequenceValues(operation Operation) { + if operation.Object.IsRoot || operation.Action == ActionMark { + return + } + + object := operation.Object.OpID + + order := s.insertOrderCache[object] + appended := operation.Insert && + len(operation.Predecessors) == 0 && + len(order) > 0 && + order[len(order)-1] == operation.ID + + if !appended { + delete(s.sequenceValuesCache, object) + delete(s.sequenceElementsCache, object) + delete(s.sequenceOffsetCache, object) + + return + } + + if cached, ok := s.sequenceValuesCache[object]; ok { + s.sequenceValuesCache[object] = append(cached, sequenceValue{ + Element: operation.ID, + Operation: operation, + }) + } + + if cached, ok := s.sequenceElementsCache[object]; ok { + s.sequenceElementsCache[object] = append(cached, operation) + + // Extend the offset index in step so appending stays constant time. A + // valid offset slice for N elements holds N+1 entries, so it lines up + // with the pre-append element count; the new element starts at the + // previous total width. + if offsets, ok := s.sequenceOffsetCache[object]; ok && + len(offsets) == len(cached)+1 { + s.sequenceOffsetCache[object] = append( + offsets, + offsets[len(offsets)-1]+elementLength(operation), + ) + } + } +} + +// sequenceOffsets returns the cumulative UTF-16 width before each element of the +// given sequence, with a trailing entry holding the total width. It is cached +// and rebuilt whenever it does not line up with the elements, so a text index +// can be resolved by binary search rather than a linear walk. +func (s *State) sequenceOffsets(object OpID, elements []Operation) []uint32 { + if cached, ok := s.sequenceOffsetCache[object]; ok && len(cached) == len(elements)+1 { + return cached + } + + offsets := make([]uint32, len(elements)+1) + for i, operation := range elements { + offsets[i+1] = offsets[i] + elementLength(operation) + } + + s.sequenceOffsetCache[object] = offsets + + return offsets +} + +// insertOrderPositions returns each insert-order operation's index. Insert order +// only ever grows, so a length mismatch is a sufficient staleness check. +func (s *State) insertOrderPositions(object OpID) map[OpID]int { + order := s.insertOrder(object) + + if cached, ok := s.insertOrderPositionCache[object]; ok && len(cached) == len(order) { + return cached + } + + positions := make(map[OpID]int, len(order)) + for index, id := range order { + positions[id] = index + } + + s.insertOrderPositionCache[object] = positions + + return positions +} + +// elementValueWinners returns, for every list element that has been assigned a +// replacement value, the visible operation with the highest ID. Element IDs are +// globally unique, so a single map covers every object. +func (s *State) elementValueWinners() map[OpID]Operation { + winners := make(map[OpID]Operation) + + for _, operation := range s.operations { + if operation.Insert || + operation.Action == ActionDelete || + operation.Action == ActionIncrement || + operation.Key.Element == nil || + s.isSuperseded(operation.ID) { + continue + } + + element := *operation.Key.Element + if current, ok := winners[element]; !ok || operation.ID.Compare(current.ID) > 0 { + winners[element] = operation + } + } + + return winners +} + +// 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) appendSequence( + output *[]Operation, + operations []Operation, + children map[OpID][]Operation, + visited map[OpID]struct{}, + includeSuperseded bool, +) { + sort.Slice( + operations, + func(i, j int) bool { + return operations[i].ID.Compare(operations[j].ID) > 0 + }, + ) + + for _, operation := range operations { + if _, ok := visited[operation.ID]; ok { + continue + } + + visited[operation.ID] = struct{}{} + if includeSuperseded || !s.isSuperseded(operation.ID) { + *output = append(*output, operation) + } + + s.appendSequence( + output, + children[operation.ID], + children, + visited, + includeSuperseded, + ) + } +} diff --git a/pkg/automerge/internal/native/state.go b/pkg/automerge/internal/native/state.go new file mode 100644 index 0000000000..60ecd5ac97 --- /dev/null +++ b/pkg/automerge/internal/native/state.go @@ -0,0 +1,804 @@ +// 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 ( + "bytes" + "fmt" + "slices" + "sort" + "strings" +) + +type ( + State struct { + changes map[ChangeHash]*Change + actorSequence map[ActorID]uint64 + operations map[OpID]Operation + superseded map[OpID]struct{} + heads map[ChangeHash]struct{} + sequenceCache map[OpID][]Operation + + // insertOrderCache holds, per sequence object, the RGA-ordered list of + // insertion operation IDs (including tombstones, excluding marks). The + // order depends only on insertion operations and their anchors, so it is + // maintained incrementally: local inserts (whose IDs are always the + // current maximum) splice in next to their anchor, while merged changes + // and rollbacks invalidate the entry so it is rebuilt lazily. + insertOrderCache map[OpID][]OpID + + // sequenceValuesCache holds the materialized visible values of a + // sequence object, and sequenceElementsCache the materialized visible + // elements. Appending a new element at the end extends them in place; + // every other mutation drops the entry so it is recomputed. + sequenceValuesCache map[OpID][]sequenceValue + sequenceElementsCache map[OpID][]Operation + + // sequenceOffsetCache holds the cumulative UTF-16 width before each + // element of sequenceElementsCache (with a trailing total), so a text + // index resolves to an element by binary search instead of a linear + // walk. It is kept in step with the elements cache and guarded by length. + sequenceOffsetCache map[OpID][]uint32 + + // insertOrderPositionCache maps each insert-order operation to its index, + // so an insertion anchor resolves in constant time instead of scanning + // the order. Insert order only ever grows, so a length guard is enough to + // detect staleness. + insertOrderPositionCache map[OpID]map[OpID]int + + // mapKeyIndex groups operation IDs by the map property they address so + // reading a key does not scan the whole operation set. It is built on + // first use and then maintained as operations are applied. + mapKeyIndex map[ObjectID]map[string][]OpID + mapKeyIndexBuilt bool + } + + RichSpan struct { + Type string `json:"type"` + Value any `json:"value"` + Marks map[string]any `json:"marks,omitempty"` + } + + sequenceValue struct { + Element OpID + Operation Operation + } +) + +func NewState() *State { + return &State{ + changes: make(map[ChangeHash]*Change), + actorSequence: make(map[ActorID]uint64), + operations: make(map[OpID]Operation), + superseded: make(map[OpID]struct{}), + heads: make(map[ChangeHash]struct{}), + sequenceCache: make(map[OpID][]Operation), + insertOrderCache: make(map[OpID][]OpID), + sequenceValuesCache: make(map[OpID][]sequenceValue), + sequenceElementsCache: make(map[OpID][]Operation), + sequenceOffsetCache: make(map[OpID][]uint32), + insertOrderPositionCache: make(map[OpID]map[OpID]int), + mapKeyIndex: make(map[ObjectID]map[string][]OpID), + } +} + +func NewStateFromDocument(document *Document) (*State, error) { + state := NewState() + + // Presize the operation and change maps so loading a large document does not + // rehash them repeatedly as it inserts every operation. + operationCount := 0 + for i := range document.Changes { + operationCount += len(document.Changes[i].Operations) + } + + state.operations = make(map[OpID]Operation, operationCount) + state.changes = make(map[ChangeHash]*Change, len(document.Changes)) + + for i := range document.Changes { + change := &document.Changes[i] + if change.Sequence > state.actorSequence[change.Actor] { + state.actorSequence[change.Actor] = change.Sequence + } + + if change.Hash != nil { + state.changes[*change.Hash] = change + } + + for _, operation := range change.Operations { + if _, exists := state.operations[operation.ID]; exists { + return nil, fmt.Errorf("duplicate snapshot operation ID %v", operation.ID) + } + + state.operations[operation.ID] = operation + for _, successor := range operation.Successors { + if successor.Counter == 0 { + return nil, fmt.Errorf("invalid zero successor for operation %v", operation.ID) + } + } + } + } + + 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{}{} + } + } + } + + consistent := true + + for _, head := range document.Heads { + if _, ok := state.changes[head]; !ok { + consistent = false + + break + } + } + + if consistent { + for _, head := range document.Heads { + state.heads[head] = struct{}{} + } + + return state, nil + } + + // The recorded frontier references a change the document does not carry, so + // it cannot be trusted. Rebuild the frontier from the change graph instead: + // a present change is a head when no other present change depends on it. This + // keeps Heads() consistent with changes so incremental reads never break. + dependedOn := make(map[ChangeHash]struct{}, len(state.changes)) + + for _, change := range state.changes { + for _, dependency := range change.Dependencies { + dependedOn[dependency] = struct{}{} + } + } + + for hash := range state.changes { + if _, ok := dependedOn[hash]; !ok { + state.heads[hash] = struct{}{} + } + } + + return state, nil +} + +func (s *State) ApplyChange(change *Change) error { + if change.Hash == nil { + return fmt.Errorf("change hash is required") + } + + if _, ok := s.changes[*change.Hash]; ok { + return nil + } + + for _, dependency := range change.Dependencies { + if _, ok := s.changes[dependency]; !ok { + return fmt.Errorf("missing change dependency %s", dependency) + } + } + + expectedSequence := s.actorSequence[change.Actor] + 1 + if change.Sequence != expectedSequence { + return fmt.Errorf( + "actor sequence is %d, expected %d", + change.Sequence, + expectedSequence, + ) + } + + for _, operation := range change.Operations { + if _, exists := s.operations[operation.ID]; exists { + return fmt.Errorf("duplicate operation ID %v", operation.ID) + } + } + + for _, operation := range change.Operations { + s.operations[operation.ID] = operation + if !operation.Object.IsRoot { + delete(s.sequenceCache, operation.Object.OpID) + delete(s.insertOrderCache, operation.Object.OpID) + delete(s.insertOrderPositionCache, operation.Object.OpID) + delete(s.sequenceValuesCache, operation.Object.OpID) + delete(s.sequenceElementsCache, operation.Object.OpID) + delete(s.sequenceOffsetCache, operation.Object.OpID) + } + + s.indexMapKeyOperation(operation) + s.supersedePredecessors(operation) + } + + s.changes[*change.Hash] = change + + s.actorSequence[change.Actor] = change.Sequence + for _, dependency := range change.Dependencies { + delete(s.heads, dependency) + } + + s.heads[*change.Hash] = struct{}{} + + return nil +} + +func (s *State) Heads() []ChangeHash { + heads := make([]ChangeHash, 0, len(s.heads)) + for head := range s.heads { + heads = append(heads, head) + } + + sort.Slice( + heads, + func(i, j int) bool { + return bytes.Compare(heads[i][:], heads[j][:]) < 0 + }, + ) + + return heads +} + +func (s *State) Text(property string) (string, error) { + objectOperation, ok := s.visibleMapOperation(property, ActionMakeText) + if !ok { + return "", fmt.Errorf("text property %q does not exist", property) + } + + sequence := s.sequence(objectOperation.ID) + + var output strings.Builder + + for _, operation := range sequence { + if operation.Value != nil && operation.Value.Type == ScalarString { + output.WriteString(operation.Value.String) + } + } + + return output.String(), nil +} + +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 != object || + operation.Key.Property == nil || + *operation.Key.Property != property || + operation.Action != action || + s.isSuperseded(operation.ID) { + continue + } + + if !found || operation.ID.Compare(result.ID) > 0 { + result = operation + found = true + } + } + + 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 := slices.Contains(increment.Predecessors, operation.ID) + + if !matches { + if slices.Contains(operation.Successors, increment.ID) { + matches = true + } + } + + if matches { + value.Int += increment.Value.Int + } + } + + return value, true +} + +func (s *State) visibleMapOperations(property string) []Operation { + return s.visibleMapObjectOperations(RootObject(), property) +} + +// mapKeyOperationIDs returns every operation addressing a map property, +// building the property index on first use. +func (s *State) mapKeyOperationIDs(object ObjectID, property string) []OpID { + if !s.mapKeyIndexBuilt { + s.mapKeyIndexBuilt = true + + for _, operation := range s.operations { + s.indexMapKeyOperation(operation) + } + } + + properties, ok := s.mapKeyIndex[object] + if !ok { + return nil + } + + return properties[property] +} + +// indexMapKeyOperation records an operation under the map property it +// addresses. It is a no-op until the index has been built, because the pending +// build will pick the operation up from the operation set. +func (s *State) indexMapKeyOperation(operation Operation) { + if !s.mapKeyIndexBuilt || operation.Key.Property == nil { + return + } + + properties, ok := s.mapKeyIndex[operation.Object] + if !ok { + properties = make(map[string][]OpID) + s.mapKeyIndex[operation.Object] = properties + } + + properties[*operation.Key.Property] = append( + properties[*operation.Key.Property], + operation.ID, + ) +} + +func (s *State) visibleMapObjectOperations( + object ObjectID, + property string, +) []Operation { + operations := make([]Operation, 0) + + for _, id := range s.mapKeyOperationIDs(object, property) { + operation, ok := s.operations[id] + if !ok || + operation.Action == ActionDelete || + operation.Action == ActionIncrement || + s.isSuperseded(operation.ID) { + continue + } + + operations = append(operations, operation) + } + + sort.Slice(operations, func(i, j int) bool { + return operations[i].ID.Compare(operations[j].ID) < 0 + }) + + 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) isSuperseded(id OpID) bool { + _, ok := s.superseded[id] + return ok +} + +func (s *State) maxOpGlobal() uint64 { + var maximum uint64 + for id := range s.operations { + if id.Counter > maximum { + maximum = id.Counter + } + } + + return maximum +} + +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 { + return fmt.Errorf("duplicate pending operation ID %v", operation.ID) + } + + s.operations[operation.ID] = operation + if !operation.Object.IsRoot { + delete(s.sequenceCache, operation.Object.OpID) + } + + s.spliceInsertOrder(operation) + s.updateSequenceValues(operation) + s.indexMapKeyOperation(operation) + s.supersedePredecessors(operation) + } + + return nil +} + +func (s *State) recordAppliedChange(change *Change) error { + if change.Hash == nil { + return fmt.Errorf("change hash is required") + } + + for _, dependency := range change.Dependencies { + delete(s.heads, dependency) + } + + s.heads[*change.Hash] = struct{}{} + s.changes[*change.Hash] = change + s.actorSequence[change.Actor] = change.Sequence + + return nil +} + +func (s *State) hasChange(hash ChangeHash) bool { + _, ok := s.changes[hash] + return ok +} + +func (s *State) hasDependencies(change *Change) bool { + for _, dependency := range change.Dependencies { + if !s.hasChange(dependency) { + return false + } + } + + return true +} + +// changesSince returns the changes reachable from the current frontier that the +// baseline heads do not already cover, in dependency order, ready to replay. +// +// The result is always a consistent prefix: a change is emitted only once every +// one of its ancestors has been emitted or is already known to the baseline, so +// a caller never receives a change whose dependency it was not also given. The +// second return reports whether that prefix is complete. It is false when some +// change in the frontier's ancestry could not be produced, either because it is +// absent from the graph or because its original bytes are unavailable. +// +// Completeness is a signal, not a gate. Sync uses it to fall back to sending a +// whole document, which reproduces changes even when the in-memory graph is +// inconsistent. Incremental reads use the prefix regardless, because returning +// every change that can be produced keeps a document usable where failing the +// whole read would wedge it: a change that cannot be emitted has no bytes to +// return anyway. +func (s *State) changesSince(heads []ChangeHash) ([]*Change, bool) { + known := s.changeClosure(heads) + + const ( + visiting = iota + reachable + unreachable + ) + + ordered := make([]*Change, 0) + status := make(map[ChangeHash]int) + + var visit func(ChangeHash) bool + + visit = func(hash ChangeHash) bool { + if state, ok := status[hash]; ok { + // A change still on the stack cannot be depended upon to be complete + // yet, but treating the cycle edge as reachable avoids excluding the + // whole branch over a graph that should never contain a cycle anyway. + return state != unreachable + } + + // The baseline closure is transitively closed, so everything below a change + // the peer already holds is also held and need not be walked. + if _, ok := known[hash]; ok { + status[hash] = reachable + + return true + } + + status[hash] = visiting + + change, ok := s.changes[hash] + if !ok { + status[hash] = unreachable + + return false + } + + complete := true + + for _, dependency := range change.Dependencies { + if !visit(dependency) { + complete = false + } + } + + // A change is emittable only when every ancestor is, so the result stays a + // replayable prefix, and only when its bytes exist to be returned. + if !complete || len(change.Raw) == 0 { + status[hash] = unreachable + + return false + } + + ordered = append(ordered, change) + status[hash] = reachable + + return true + } + + complete := true + + for _, head := range s.Heads() { + // A frontier head whose change is not retrievable contributes nothing. + if _, ok := s.changes[head]; !ok { + complete = false + + continue + } + + if !visit(head) { + complete = false + } + } + + return ordered, complete +} + +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 _, ok := s.changes[head]; !ok { + continue + } + + 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 +} + +// changeClosure returns every change reachable from the given baseline heads. +// A head whose change is not present is skipped rather than failing: it excludes +// nothing from the result, so an incremental computation over-approximates (it +// may resend changes the peer already has, which is safe) instead of aborting. +// This keeps sync and persistence working even when a frontier references a +// change that is no longer retrievable, for example after a merge that rebuilt +// the change graph. +func (s *State) changeClosure(heads []ChangeHash) map[ChangeHash]struct{} { + closure := make(map[ChangeHash]struct{}) + + pending := append([]ChangeHash(nil), heads...) + + for len(pending) > 0 { + index := len(pending) - 1 + hash := pending[index] + pending = pending[:index] + + if _, ok := closure[hash]; ok { + continue + } + + change, ok := s.changes[hash] + if !ok { + continue + } + + closure[hash] = struct{}{} + + pending = append(pending, change.Dependencies...) + } + + return closure +} diff --git a/pkg/automerge/internal/native/storage.go b/pkg/automerge/internal/native/storage.go new file mode 100644 index 0000000000..7d6b3b0a06 --- /dev/null +++ b/pkg/automerge/internal/native/storage.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 ( + internalencoding "go.probo.inc/probo/pkg/automerge/internal/encoding" + internalstorage "go.probo.inc/probo/pkg/automerge/internal/storage" +) + +var ( + Decode = internalstorage.Decode + DecodePartial = internalstorage.DecodePartial + DecodeIncremental = internalstorage.DecodeIncremental + EncodeChange = internalstorage.EncodeChange + EncodeDocument = internalstorage.EncodeDocument +) + +func deflate(data []byte) ([]byte, error) { return internalstorage.Deflate(data) } + +// Small compatibility wrapper while higher-level engine code migrates to the +// shared encoding package. +type reader struct{ inner *internalencoding.Reader } + +func newReader(data []byte) *reader { return &reader{inner: internalencoding.NewReader(data)} } +func newReaderAt(data []byte, offset int) *reader { + return &reader{inner: internalencoding.NewReaderAt(data, offset)} +} +func (r *reader) remaining() int { return r.inner.Remaining() } +func (r *reader) offset() int { return r.inner.Offset() } +func (r *reader) byte() (byte, error) { return r.inner.Byte() } +func (r *reader) bytes(length uint64) ([]byte, error) { return r.inner.Bytes(length) } +func (r *reader) uleb() (uint64, error) { return r.inner.ULEB() } +func appendULEB(data []byte, value uint64) []byte { return internalencoding.AppendULEB(data, value) } +func appendLengthPrefixedNative(data, value []byte) []byte { + return internalencoding.AppendLengthPrefixed(data, value) +} +func decodeLengthPrefixed(r *reader) ([]byte, error) { + return internalencoding.DecodeLengthPrefixed(r.inner) +} diff --git a/pkg/automerge/internal/native/sync.go b/pkg/automerge/internal/native/sync.go new file mode 100644 index 0000000000..f98bebd41b --- /dev/null +++ b/pkg/automerge/internal/native/sync.go @@ -0,0 +1,36 @@ +// 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 internalsync "go.probo.inc/probo/pkg/automerge/internal/sync" + +type ( + SyncMessageVersion = internalsync.MessageVersion + SyncHave = internalsync.Have + SyncMessage = internalsync.Message +) + +const ( + SyncMessageVersion1 = internalsync.MessageVersion1 + SyncMessageVersion2 = internalsync.MessageVersion2 +) + +var ParseSyncMessage = internalsync.ParseMessage diff --git a/pkg/automerge/internal/native/sync_engine.go b/pkg/automerge/internal/native/sync_engine.go new file mode 100644 index 0000000000..561505ea80 --- /dev/null +++ b/pkg/automerge/internal/native/sync_engine.go @@ -0,0 +1,390 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "fmt" + "sort" +) + +func (b *Engine) NewSyncState(ctx context.Context) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + handle := b.nextSyncState + b.nextSyncState++ + b.syncStates[handle] = &nativeSyncState{} + + return handle, nil +} + +func (b *Engine) CloseSyncState(ctx context.Context, handle uint32) error { + if err := ctx.Err(); err != nil { + return err + } + + if _, ok := b.syncStates[handle]; !ok { + return fmt.Errorf("invalid sync state %d", handle) + } + + delete(b.syncStates, handle) + + return nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) GenerateSyncMessage( + ctx context.Context, + handle uint32, +) ([]byte, bool, error) { + if err := ctx.Err(); err != nil { + return nil, false, err + } + + state, err := b.syncState(handle) + if err != nil { + return nil, false, err + } + + heads, err := b.Heads(ctx) + if err != nil { + return nil, false, err + } + + // 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. A Need + // that is unchanged since the last message we sent is not new information: + // re-requesting the same missing dependencies (for example an orphan change + // whose base never arrives) would otherwise regenerate an identical message + // forever and never quiesce. + if state.InFlight && + !state.ModeChanged && + !state.NeedsReset && + len(state.Requested) == 0 && + equalHashes(state.Need, state.LastSentNeed) && + 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 && + equalHashes(state.Need, state.LastSentNeed) && + equalHashes(heads, state.LastSentHeads) { + return nil, false, nil + } + + if state.ReadOnly && + !state.ModeChanged && + !state.NeedsReset && + !state.NeedsAck && + len(state.Requested) == 0 && + equalHashes(state.Need, state.LastSentNeed) && + equalHashes(heads, state.LastSentHeads) { + return nil, false, nil + } + + if !state.NeedsAck && + !state.ModeChanged && + !state.NeedsReset && + len(state.Requested) == 0 && + equalHashes(state.Need, state.LastSentNeed) && + 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: messageHeads, + Need: append([][32]byte(nil), state.Need...), + Flags: []byte{2, syncFlagMarker | flags}, + } + if !state.NeedsAck && !state.PeerReadOnly { + switch { + case len(state.Requested) > 0: + for _, requested := range state.Requested { + change, ok := b.state.changes[ChangeHash(requested)] + if !ok || len(change.Raw) == 0 { + continue + } + + message.Changes = append( + message.Changes, + append([]byte(nil), change.Raw...), + ) + } + + state.Requested = nil + case len(state.Need) == 0: + remoteHeads := make([]ChangeHash, len(state.RemoteHeads)) + for i, head := range state.RemoteHeads { + remoteHeads[i] = ChangeHash(head) + } + + changes, incremental := b.state.changesSince(remoteHeads) + if incremental { + for _, change := range changes { + message.Changes = append( + message.Changes, + append([]byte(nil), change.Raw...), + ) + } + } else { + document, err := b.Save(ctx, true, true) + if err != nil { + return nil, false, err + } + + message.Changes = [][]byte{document} + } + } + } + + if !state.NeedsAck { + state.InFlight = true + } + + state.NeedsAck = false + state.Sent = true + state.LastSentHeads = append(state.LastSentHeads[:0], heads...) + state.LastSentNeed = append(state.LastSentNeed[:0], state.Need...) + state.PeerModeChanged = false + state.ModeChanged = false + state.NeedsReset = false + + data, err := message.Encode() + if err != nil { + return nil, false, err + } + + return data, true, nil +} + +func (b *Engine) ReceiveSyncMessage( + ctx context.Context, + handle uint32, + data []byte, +) error { + state, err := b.syncState(handle) + if err != nil { + return err + } + + message, err := ParseSyncMessage(data) + if err != nil { + return err + } + + state.InFlight = false + + 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) + } + } + } + + state.RemoteHeads = append([][32]byte(nil), message.Heads...) + if state.PeerReadOnly { + // A read-only peer cannot receive changes. Retaining a Need it sent + // before (or together with) the mode transition makes every generation + // attempt to service an impossible request and prevents quiescence. When + // the peer becomes writable it will advertise the missing heads again. + state.Requested = nil + } else { + state.Requested = append(state.Requested[:0], message.Need...) + } + + needed := make(map[[32]byte]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{}{} + } + } + } + } + + state.Need = state.Need[:0] + for dependency := range needed { + state.Need = append(state.Need, dependency) + } + + sort.Slice(state.Need, func(i, j int) bool { + return bytes.Compare(state.Need[i][:], state.Need[j][:]) < 0 + }) + + state.NeedsAck = len(message.Changes) > 0 || state.PeerModeChanged + + return nil +} + +func (b *Engine) SaveSyncState( + ctx context.Context, + handle uint32, +) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + state, err := b.syncState(handle) + if err != nil { + return nil, err + } + + data, err := json.Marshal(state) + if err != nil { + return nil, fmt.Errorf("cannot encode native sync state: %w", err) + } + + return data, nil +} + +func (b *Engine) LoadSyncState( + ctx context.Context, + data []byte, +) (uint32, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + + var state nativeSyncState + if err := json.Unmarshal(data, &state); err != nil { + 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 + + return handle, nil +} diff --git a/pkg/automerge/internal/native/text_diff.go b/pkg/automerge/internal/native/text_diff.go new file mode 100644 index 0000000000..0e30fc8423 --- /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 *Engine) 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, engine: 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 + engine *Engine + 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 := range length { + h.idx += utf16Width(h.old[oldIndex+i]) + } +} + +func (h *textDiffHook) delete(oldIndex, oldLen, _ int) { + if h.err != nil { + return + } + + deleted := 0 + + for i := range oldLen { + deleted += utf16Width(h.old[oldIndex+i]) + } + + if err := h.engine.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 := range newLen { + builder.WriteString(h.new[newIndex+i]) + } + + chars := builder.String() + if err := h.engine.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 := range dMax { + 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/transaction.go b/pkg/automerge/internal/native/transaction.go new file mode 100644 index 0000000000..637033e758 --- /dev/null +++ b/pkg/automerge/internal/native/transaction.go @@ -0,0 +1,301 @@ +// 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 ( + "bytes" + "context" + "fmt" + "slices" + "sort" + "time" +) + +// 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 *Engine) 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 { + return slices.Contains(hashes, target) +} + +// 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 *Engine) 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.revision++ + + 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 *Engine) 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 + b.revision++ + + 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 *Engine) 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 + b.revision++ + + return [32]byte(*change.Hash), nil +} + +func (b *Engine) 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) + b.revision++ + + return [32]byte(*change.Hash), nil +} + +func (b *Engine) 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 + b.revision++ + + return cancelled, nil +} diff --git a/pkg/automerge/internal/opset/opset.go b/pkg/automerge/internal/opset/opset.go new file mode 100644 index 0000000000..5c8619978c --- /dev/null +++ b/pkg/automerge/internal/opset/opset.go @@ -0,0 +1,241 @@ +// 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 opset defines the shared Automerge operation-set model: actors, +// operation IDs, operations, changes, scalars, objects and the validated +// document history. It is dependency-free so the storage, sync, and native +// execution packages can exchange changes without importing one another. +package opset + +import ( + "bytes" + "encoding/hex" + "fmt" + "math" +) + +type ( + // ActorID is an immutable, arbitrary-length Automerge actor identifier. + ActorID string + + // ChangeHash identifies a change by the SHA-256 digest of its change chunk. + ChangeHash [32]byte + + // OpID is an Automerge Lamport timestamp. + OpID struct { + Actor ActorID + Counter uint64 + } + + // ObjectID identifies either the root map or the operation that made an + // object. + ObjectID struct { + OpID OpID + IsRoot bool + } + + // Key identifies either a map property or a sequence element. + Key struct { + Property *string + Element *OpID + IsHead bool + } + + // Action is the numeric action stored in an operation. + Action uint64 + + // ScalarType is the four-bit scalar tag from a value metadata column. + ScalarType uint8 + + // Scalar preserves the exact Automerge scalar type and its typed value. + // Raw is populated for unknown, forward-compatible scalar types. + Scalar struct { + Type ScalarType + Bool bool + Uint uint64 + Int int64 + Float float64 + String string + Bytes []byte + Raw []byte + } + + // Operation is one decoded Automerge operation. + Operation struct { + ID OpID + Object ObjectID + Key Key + Insert bool + Action Action + Value *Scalar + Predecessors []OpID + Successors []OpID + MarkExpand *bool + MarkName *string + } + + // Change is one decoded change. Hash is nil for non-head changes in a + // document chunk because snapshots store only head hashes. + Change struct { + Hash *ChangeHash + Actor ActorID + Sequence uint64 + StartOp uint64 + MaxOp uint64 + Time int64 + Message string + Dependencies []ChangeHash + DependencyIndexes []uint64 + Operations []Operation + Extra *Scalar + ExtraBytes []byte + Raw []byte + } + + // ChunkType identifies an Automerge storage chunk. + ChunkType uint8 + + // RawColumn retains a column not interpreted by this milestone. + RawColumn struct { + Specification uint32 + Data []byte + } + + // Document is a validated Automerge history. + Document struct { + Actors []ActorID + Heads []ChangeHash + Changes []Change + UnknownColumns []RawColumn + ChunkTypes []ChunkType + } +) + +const ( + ActionMakeMap Action = 0 + ActionSet Action = 1 + ActionMakeList Action = 2 + ActionDelete Action = 3 + ActionMakeText Action = 4 + ActionIncrement Action = 5 + ActionMakeTable Action = 6 + ActionMark Action = 7 + + ScalarNull ScalarType = 0 + ScalarFalse ScalarType = 1 + ScalarTrue ScalarType = 2 + ScalarUint ScalarType = 3 + ScalarInt ScalarType = 4 + ScalarFloat64 ScalarType = 5 + ScalarString ScalarType = 6 + ScalarBytes ScalarType = 7 + ScalarCounter ScalarType = 8 + ScalarTimestamp ScalarType = 9 + + ChunkDocument ChunkType = 0 + ChunkChange ChunkType = 1 + ChunkCompressedChange ChunkType = 2 +) + +// NewActorID validates and copies an actor ID. +func NewActorID(value []byte) (ActorID, error) { + if len(value) == 0 { + return "", fmt.Errorf("actor ID cannot be empty") + } + + return ActorID(string(value)), nil +} + +// Bytes returns a copy of the actor ID bytes. +func (a ActorID) Bytes() []byte { + return []byte(string(a)) +} + +// String returns the lowercase hexadecimal actor ID. +func (a ActorID) String() string { + return hex.EncodeToString(a.Bytes()) +} + +// Compare returns -1, 0, or 1 using Automerge's bytewise actor ordering. +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[:]) +} + +// Compare orders operation IDs by their Automerge Lamport timestamp. +func (o OpID) Compare(other OpID) int { + switch { + case o.Counter < other.Counter: + return -1 + case o.Counter > other.Counter: + return 1 + default: + return o.Actor.Compare(other.Actor) + } +} + +// RootObject returns the distinguished root map identifier. +func RootObject() ObjectID { + return ObjectID{IsRoot: true} +} + +// IsKnown reports whether the scalar type is defined by Automerge 0.10. +func (s Scalar) IsKnown() bool { + return s.Type <= ScalarTimestamp +} + +// IsFinite reports whether a float scalar is finite. Non-float scalars are +// always finite. +func (s Scalar) IsFinite() bool { + return s.Type != ScalarFloat64 || (!math.IsInf(s.Float, 0) && !math.IsNaN(s.Float)) +} + +func appendULEB(data []byte, value uint64) []byte { + for value >= 0x80 { + data = append(data, byte(value)|0x80) + value >>= 7 + } + + return append(data, byte(value)) +} diff --git a/pkg/automerge/internal/reference/reference.go b/pkg/automerge/internal/reference/reference.go new file mode 100644 index 0000000000..9c6946d979 --- /dev/null +++ b/pkg/automerge/internal/reference/reference.go @@ -0,0 +1,1979 @@ +// 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 reference + +import ( + "context" + "crypto/rand" + "crypto/sha256" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +//go:embed reference.wasm +var wasm []byte + +//go:embed reference.wasm.sha256 +var wasmChecksum []byte + +const ( + referenceABIVersion uint64 = 1 + referenceMemoryLimitPages uint32 = 1_024 +) + +type ( + Object = uint32 + + Engine struct { + module api.Module + } +) + +var ( + runtimeOnce sync.Once + runtimeInstance wazero.Runtime + compiledModule wazero.CompiledModule + runtimeErr error + moduleSequence atomic.Uint64 +) + +func New(ctx context.Context) (*Engine, error) { + backend, err := instantiate(ctx) + if err != nil { + return nil, fmt.Errorf("cannot instantiate Automerge reference backend: %w", err) + } + + if err := backend.run(ctx, "am_create"); err != nil { + _ = backend.Close(ctx) + return nil, fmt.Errorf("cannot create Automerge document: %w", err) + } + + return backend, nil +} + +func Load(ctx context.Context, document []byte) (*Engine, 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", document); err != nil { + _ = backend.Close(ctx) + return nil, fmt.Errorf("cannot load Automerge document: %w", err) + } + + 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) (*Engine, 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) (*Engine, error) { + runtimeOnce.Do( + func() { + fields := strings.Fields(string(wasmChecksum)) + if len(fields) == 0 { + runtimeErr = fmt.Errorf("automerge reference checksum is empty") + return + } + + checksum := sha256.Sum256(wasm) + + actualChecksum := hex.EncodeToString(checksum[:]) + if !strings.EqualFold(fields[0], actualChecksum) { + runtimeErr = fmt.Errorf( + "automerge reference checksum mismatch: expected %s, got %s", + fields[0], + actualChecksum, + ) + + return + } + + runtimeContext := context.Background() + + runtimeInstance = wazero.NewRuntimeWithConfig( + runtimeContext, + wazero.NewRuntimeConfig().WithMemoryLimitPages(referenceMemoryLimitPages), + ) + if _, err := wasi_snapshot_preview1.Instantiate(runtimeContext, runtimeInstance); err != nil { + runtimeErr = fmt.Errorf("cannot instantiate WASI: %w", err) + return + } + + compiledModule, runtimeErr = runtimeInstance.CompileModule(runtimeContext, wasm) + if runtimeErr != nil { + runtimeErr = fmt.Errorf("cannot compile reference module: %w", runtimeErr) + } + }, + ) + + if runtimeErr != nil { + return nil, runtimeErr + } + + name := fmt.Sprintf("automerge-reference-%d", moduleSequence.Add(1)) + + module, err := runtimeInstance.InstantiateModule( + ctx, + compiledModule, + wazero.NewModuleConfig().WithName(name).WithRandSource(rand.Reader), + ) + if err != nil { + return nil, fmt.Errorf("cannot instantiate reference module: %w", err) + } + + backend := &Engine{module: module} + + version, err := backend.call(ctx, "am_abi_version") + if err != nil { + _ = module.Close(ctx) + return nil, fmt.Errorf("cannot read reference ABI version: %w", err) + } + + if version[0] != referenceABIVersion { + _ = module.Close(ctx) + + return nil, fmt.Errorf( + "unsupported reference ABI version %d, expected %d", + version[0], + referenceABIVersion, + ) + } + + return backend, nil +} + +func (b *Engine) Close(ctx context.Context) error { + if err := b.module.Close(ctx); err != nil { + return fmt.Errorf("cannot close reference module: %w", err) + } + + return nil +} + +// Save serializes the document. retainOrphans keeps changes whose dependencies +// are missing; compress DEFLATEs the output. The reference exposes three save +// entry points, so the flag combination maps onto the closest one: the default +// am_save (retain, compress), am_save_nocompress (retain, no compress), and +// am_save_no_orphans (discard orphans, which also does not compress). +func (b *Engine) Save( + ctx context.Context, + retainOrphans bool, + compress bool, +) ([]byte, error) { + function := "am_save" + + switch { + case !retainOrphans: + function = "am_save_no_orphans" + case !compress: + function = "am_save_nocompress" + } + + if err := b.run(ctx, function); 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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) + } + + return nil +} + +func (b *Engine) PutString(ctx context.Context, object Object, key, value string) error { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + return fmt.Errorf("cannot write map key: %w", err) + } + defer b.free(ctx, keyPointer, keyLength) + + valuePointer, valueLength, err := b.write(ctx, []byte(value)) + if err != nil { + return fmt.Errorf("cannot write map value: %w", err) + } + defer b.free(ctx, valuePointer, valueLength) + + if err := b.run( + ctx, + "am_put_string", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + uint64(valuePointer), + uint64(valueLength), + ); err != nil { + return fmt.Errorf("cannot put reference map value: %w", err) + } + + return nil +} + +func (b *Engine) GetString(ctx context.Context, object Object, key string) (string, error) { + keyPointer, keyLength, err := b.write(ctx, []byte(key)) + if err != nil { + 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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_object", + uint64(object), + uint64(keyPointer), + uint64(keyLength), + uint64(typePointer), + uint64(typeLength), + ) + if err != nil { + 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 object") + } + + return Object(handle), nil +} + +func (b *Engine) 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 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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) + } + + return output, nil +} + +func (b *Engine) TextCursor(ctx context.Context, object Object, index uint32) ([]byte, error) { + if err := b.run( + ctx, + "am_text_cursor", + uint64(object), + uint64(index), + ); err != nil { + return nil, fmt.Errorf("cannot create 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 cursor, nil +} + +func (b *Engine) TextCursorMoving( + ctx context.Context, + object Object, + index uint32, + moveBefore bool, +) ([]byte, error) { + var movement uint64 + if moveBefore { + movement = 1 + } + + if err := b.run( + ctx, + "am_text_cursor_moving", + uint64(object), + uint64(index), + movement, + ); err != nil { + return nil, fmt.Errorf("cannot create moving reference text cursor: %w", err) + } + + cursor, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy moving reference text cursor: %w", err) + } + + return cursor, nil +} + +func (b *Engine) TextCursorMovingAt( + ctx context.Context, + object Object, + index uint32, + moveBefore bool, + heads [][32]byte, +) ([]byte, error) { + var movement uint64 + if moveBefore { + movement = 1 + } + + pointer, length, err := b.write(ctx, flattenHashes(heads)) + if err != nil { + return nil, fmt.Errorf("cannot write cursor heads: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_text_cursor_moving_at", + uint64(object), + uint64(index), + movement, + uint64(pointer), + uint64(length), + ); err != nil { + 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 historical reference text cursor: %w", err) + } + + return cursor, nil +} + +func (b *Engine) TextCursorPosition( + ctx context.Context, + object Object, + cursor []byte, +) (uint32, error) { + pointer, length, err := b.write(ctx, cursor) + if err != nil { + return 0, fmt.Errorf("cannot write reference text cursor: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call( + ctx, + "am_text_cursor_position", + uint64(object), + uint64(pointer), + uint64(length), + ) + if err != nil { + return 0, fmt.Errorf("cannot resolve reference text cursor: %w", err) + } + + position := int64(result[0]) + if position < 0 { + return 0, b.operationError(ctx, "cannot resolve reference text cursor") + } + + if position > int64(^uint32(0)) { + return 0, fmt.Errorf("cannot resolve reference text cursor: position %d exceeds uint32", position) + } + + return uint32(position), nil +} + +func (b *Engine) Commit( + 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 commit message: %w", err) + } + defer b.free(ctx, messagePointer, messageLength) + + if err := b.run( + ctx, + "am_commit", + uint64(messagePointer), + uint64(messageLength), + uint64(timestampSeconds), + ); err != nil { + return hash, fmt.Errorf("cannot commit reference document: %w", err) + } + + output, err := b.output(ctx) + if err != nil { + return hash, fmt.Errorf("cannot copy reference commit hash: %w", err) + } + + if len(output) != len(hash) { + return hash, fmt.Errorf("cannot decode reference commit hash: expected 32 bytes, got %d", len(output)) + } + + copy(hash[:], output) + + return hash, nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference heads: %w", err) + } + + if len(output)%32 != 0 { + return nil, fmt.Errorf("cannot decode reference heads: length %d is not divisible by 32", len(output)) + } + + heads := make([][32]byte, len(output)/32) + for i := range heads { + copy(heads[i][:], output[i*32:(i+1)*32]) + } + + return heads, nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) 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 *Engine) 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) + } + + output, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy merged reference heads: %w", err) + } + + if len(output)%32 != 0 { + return nil, fmt.Errorf("cannot decode merged reference heads: length %d is not divisible by 32", len(output)) + } + + heads := make([][32]byte, len(output)/32) + for i := range heads { + copy(heads[i][:], output[i*32:(i+1)*32]) + } + + return heads, nil +} + +func (b *Engine) NewSyncState(ctx context.Context) (uint32, error) { + result, err := b.call(ctx, "am_sync_new") + if err != nil { + return 0, fmt.Errorf("cannot create reference sync state: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot create reference sync state") + } + + return uint32(handle), nil +} + +func (b *Engine) CloseSyncState(ctx context.Context, handle uint32) error { + if err := b.run(ctx, "am_sync_free", uint64(handle)); err != nil { + return fmt.Errorf("cannot close reference sync state: %w", err) + } + + return nil +} + +func (b *Engine) 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 *Engine) 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 *Engine) GenerateSyncMessage( + ctx context.Context, + handle uint32, +) ([]byte, bool, error) { + if err := b.run(ctx, "am_sync_generate", uint64(handle)); err != nil { + return nil, false, fmt.Errorf("cannot generate reference sync message: %w", err) + } + + message, err := b.output(ctx) + if err != nil { + return nil, false, fmt.Errorf("cannot copy reference sync message: %w", err) + } + + return message, len(message) > 0, nil +} + +func (b *Engine) ReceiveSyncMessage(ctx context.Context, handle uint32, message []byte) error { + pointer, length, err := b.write(ctx, message) + if err != nil { + return fmt.Errorf("cannot write reference sync message: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run( + ctx, + "am_sync_receive", + uint64(handle), + uint64(pointer), + uint64(length), + ); err != nil { + return fmt.Errorf("cannot receive reference sync message: %w", err) + } + + return nil +} + +func (b *Engine) SaveSyncState(ctx context.Context, handle uint32) ([]byte, error) { + if err := b.run(ctx, "am_sync_save", uint64(handle)); err != nil { + return nil, fmt.Errorf("cannot save reference sync state: %w", err) + } + + data, err := b.output(ctx) + if err != nil { + return nil, fmt.Errorf("cannot copy reference sync state: %w", err) + } + + return data, nil +} + +func (b *Engine) LoadSyncState(ctx context.Context, data []byte) (uint32, error) { + pointer, length, err := b.write(ctx, data) + if err != nil { + return 0, fmt.Errorf("cannot write reference sync state: %w", err) + } + defer b.free(ctx, pointer, length) + + result, err := b.call(ctx, "am_sync_load", uint64(pointer), uint64(length)) + if err != nil { + return 0, fmt.Errorf("cannot load reference sync state: %w", err) + } + + handle := int64(result[0]) + if handle < 0 { + return 0, b.operationError(ctx, "cannot load reference sync state") + } + + return uint32(handle), nil +} + +func (b *Engine) runBytes(ctx context.Context, function string, value []byte) error { + pointer, length, err := b.write(ctx, value) + if err != nil { + return fmt.Errorf("cannot write operation input: %w", err) + } + defer b.free(ctx, pointer, length) + + if err := b.run(ctx, function, uint64(pointer), uint64(length)); err != nil { + return fmt.Errorf("cannot run operation with input: %w", err) + } + + return nil +} + +func (b *Engine) run(ctx context.Context, function string, parameters ...uint64) error { + result, err := b.call(ctx, function, parameters...) + if err != nil { + return fmt.Errorf("cannot call %s: %w", function, err) + } + + if int32(result[0]) != 0 { + return b.operationError(ctx, fmt.Sprintf("%s failed", function)) + } + + return nil +} + +func (b *Engine) operationError(ctx context.Context, fallback string) error { + result, err := b.call(ctx, "am_error_len") + if err != nil { + return fmt.Errorf("%s: cannot read error length: %w", fallback, err) + } + + length := uint32(result[0]) + if length == 0 { + return fmt.Errorf("%s", fallback) + } + + pointer, err := b.alloc(ctx, length) + if err != nil { + return fmt.Errorf("%s: cannot allocate error buffer: %w", fallback, err) + } + defer b.free(ctx, pointer, length) + + if _, err := b.call(ctx, "am_error_copy", uint64(pointer)); err != nil { + return fmt.Errorf("%s: cannot copy error: %w", fallback, err) + } + + message, err := b.read(pointer, length) + if err != nil { + return fmt.Errorf("%s: cannot read error: %w", fallback, err) + } + + return fmt.Errorf("%s: %s", fallback, message) +} + +func (b *Engine) output(ctx context.Context) ([]byte, error) { + result, err := b.call(ctx, "am_output_len") + if err != nil { + return nil, fmt.Errorf("cannot read output length: %w", err) + } + + length := uint32(result[0]) + if length == 0 { + return nil, nil + } + + pointer, err := b.alloc(ctx, length) + if err != nil { + return nil, fmt.Errorf("cannot allocate output buffer: %w", err) + } + defer b.free(ctx, pointer, length) + + if _, err := b.call(ctx, "am_output_copy", uint64(pointer)); err != nil { + return nil, fmt.Errorf("cannot copy output: %w", err) + } + + output, err := b.read(pointer, length) + if err != nil { + return nil, fmt.Errorf("cannot read output: %w", err) + } + + return output, nil +} + +func (b *Engine) call(ctx context.Context, function string, parameters ...uint64) ([]uint64, error) { + exported := b.module.ExportedFunction(function) + if exported == nil { + return nil, fmt.Errorf("reference module does not export %q", function) + } + + result, err := exported.Call(ctx, parameters...) + if err != nil { + return nil, fmt.Errorf("cannot execute reference export %q: %w", function, err) + } + + return result, nil +} + +func (b *Engine) alloc(ctx context.Context, length uint32) (uint32, error) { + result, err := b.call(ctx, "am_alloc", uint64(length)) + if err != nil { + return 0, fmt.Errorf("cannot allocate reference memory: %w", err) + } + + pointer := uint32(result[0]) + if pointer == 0 && length > 0 { + return 0, fmt.Errorf("cannot allocate %d bytes of reference memory", length) + } + + return pointer, nil +} + +func (b *Engine) free(ctx context.Context, pointer, length uint32) { + if pointer == 0 || length == 0 { + return + } + + _, _ = b.call(ctx, "am_free", uint64(pointer), uint64(length)) +} + +func (b *Engine) write(ctx context.Context, value []byte) (uint32, uint32, error) { + length := uint32(len(value)) + if length == 0 { + return 0, 0, nil + } + + pointer, err := b.alloc(ctx, length) + if err != nil { + return 0, 0, fmt.Errorf("cannot allocate input: %w", err) + } + + if !b.module.Memory().Write(pointer, value) { + b.free(ctx, pointer, length) + return 0, 0, fmt.Errorf("cannot write %d bytes at reference memory offset %d", length, pointer) + } + + return pointer, length, nil +} + +func (b *Engine) read(pointer, length uint32) ([]byte, error) { + value, ok := b.module.Memory().Read(pointer, length) + if !ok { + return nil, fmt.Errorf("cannot read %d bytes at reference memory offset %d", length, pointer) + } + + 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 new file mode 100755 index 0000000000..18dd8e919a Binary files /dev/null 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 new file mode 100644 index 0000000000..d8fb66577a --- /dev/null +++ b/pkg/automerge/internal/reference/reference.wasm.sha256 @@ -0,0 +1 @@ +a2d9e799d554ea07396c3f4f56e31aba367474418485e92a9bf31a77bb93c74e reference.wasm diff --git a/pkg/automerge/internal/reference/wasm/Cargo.lock b/pkg/automerge/internal/reference/wasm/Cargo.lock new file mode 100644 index 0000000000..86c5699a6b --- /dev/null +++ b/pkg/automerge/internal/reference/wasm/Cargo.lock @@ -0,0 +1,471 @@ +# 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-reference" +version = "0.1.0" +dependencies = [ + "automerge", + "hex", + "serde_json", +] + +[[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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +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/pkg/automerge/internal/reference/wasm/Cargo.toml b/pkg/automerge/internal/reference/wasm/Cargo.toml new file mode 100644 index 0000000000..9eb32d397f --- /dev/null +++ b/pkg/automerge/internal/reference/wasm/Cargo.toml @@ -0,0 +1,41 @@ +# 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-reference" +version = "0.1.0" +edition = "2021" +license = "MIT" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +automerge = { version = "=0.10.0", features = ["utf16-indexing"] } +hex = "0.4.3" +serde_json = "1" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = "z" +panic = "abort" +strip = true diff --git a/pkg/automerge/internal/reference/wasm/deny.toml b/pkg/automerge/internal/reference/wasm/deny.toml new file mode 100644 index 0000000000..58b6d073f0 --- /dev/null +++ b/pkg/automerge/internal/reference/wasm/deny.toml @@ -0,0 +1,257 @@ +# 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 template contains all of the possible sections and their default values + +# Note that all fields that take a lint level have these possible values: +# * deny - An error will be produced and the check will fail +# * warn - A warning will be produced, but the check will not fail +# * allow - No warning or error will be produced, though in some cases a note +# will be + +# The values provided in this template are the default values that will be used +# when any section or field is not specified in your own configuration + +# Root options + +# The graph table configures how the dependency graph is constructed and thus +# which crates the checks are performed against +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [ + "wasm32-wasip1", + # You can also specify which target_features you promise are enabled for a + # particular target. target_features are currently not validated against + # the actual valid features supported by the target architecture. + #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, +] +# When creating the dependency graph used as the source of truth when checks are +# executed, this field can be used to prune crates from the graph, removing them +# from the view of cargo-deny. This is an extremely heavy hammer, as if a crate +# is pruned from the graph, all of its dependencies will also be pruned unless +# they are connected to another crate in the graph that hasn't been pruned, +# so it should be used with care. The identifiers are [Package ID Specifications] +# (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) +#exclude = [] +# If true, metadata will be collected with `--all-features`. Note that this can't +# be toggled off if true, if you want to conditionally enable `--all-features` it +# is recommended to pass `--all-features` on the cmd line instead +all-features = false +# If true, metadata will be collected with `--no-default-features`. The same +# caveat with `all-features` applies +no-default-features = false +# If set, these feature will be enabled when collecting metadata. If `--features` +# is specified on the cmd line they will take precedence over this option. +#features = [] + +# The output table provides options for how/if diagnostics are outputted +[output] +# When outputting inclusion graphs in diagnostics that include features, this +# option can be used to specify the depth at which feature edges will be added. +# This option is included since the graphs can be quite large and the addition +# of features from the crate(s) to all of the graph roots can be far too verbose. +# This option can be overridden via `--feature-depth` on the cmd line +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +# The path where the advisory databases are cloned/fetched into +#db-path = "$CARGO_HOME/advisory-dbs" +# The url(s) of the advisory databases to use +#db-urls = ["https://github.com/rustsec/advisory-db"] +# A list of advisory IDs to ignore. Note that ignored advisories will still +# output a note when they are encountered. +ignore = [ + #"RUSTSEC-0000-0000", + #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, + #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish + #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, +] +# If this is true, then cargo deny will use the git executable to fetch advisory database. +# If this is false, then it uses a built-in git library. +# Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. +# See Git Authentication for more information about setting up git authentication. +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "MIT", + "Apache-2.0", + "Unicode-3.0", + "Zlib", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [ + # Each entry is the crate and version constraint, and its specific allow + # list + #{ allow = ["Zlib"], crate = "adler32" }, +] + +# Some crates don't have (easily) machine readable licensing information, +# adding a clarification entry for it allows you to manually specify the +# licensing information +#[[licenses.clarify]] +# The package spec the clarification applies to +#crate = "ring" +# The SPDX expression for the license requirements of the crate +#expression = "MIT AND ISC AND OpenSSL" +# One or more files in the crate's source used as the "source of truth" for +# the license expression. If the contents match, the clarification will be used +# when running the license check, otherwise the clarification will be ignored +# and the crate will be checked normally, which may produce warnings or errors +# depending on the rest of your configuration +#license-files = [ +# Each entry is a crate relative path, and the (opaque) hash of its contents +#{ path = "LICENSE", hash = 0xbd0eed23 } +#] + +[licenses.private] +# If true, ignores workspace crates that aren't published, or are only +# published to private registries. +# To see how to mark a crate as unpublished (to the official registry), +# visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. +ignore = true +# One or more private registries that you might publish crates to, if a crate +# is only published to private registries, and ignore is true, the crate will +# not have its license(s) checked +registries = [ + #"https://sekretz.com/registry +] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +# Lint level for when multiple versions of the same crate are detected +multiple-versions = "warn" +# Lint level for when a crate version requirement is `*` +wildcards = "deny" +# The graph highlighting used when creating dotgraphs for crates +# with multiple versions +# * lowest-version - The path to the lowest versioned duplicate is highlighted +# * simplest-path - The path to the version with the fewest edges is highlighted +# * all - Both lowest-version and simplest-path are used +highlight = "all" +# The default lint level for `default` features for crates that are members of +# the workspace that is being checked. This can be overridden by allowing/denying +# `default` on a crate-by-crate basis if desired. +workspace-default-features = "allow" +# The default lint level for `default` features for external crates that are not +# members of the workspace. This can be overridden by allowing/denying `default` +# on a crate-by-crate basis if desired. +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is allowed" }, +] +# If true, workspace members are automatically allowed even when using deny-by-default +# This is useful for organizations that want to deny all external dependencies by default +# but allow their own workspace crates without having to explicitly list them +allow-workspace = false +# List of crates to deny +deny = [ + #"ansi_term@0.11.0", + #{ crate = "ansi_term@0.11.0", reason = "you can specify a reason it is banned" }, + # Wrapper crates can optionally be specified to allow the crate when it + # is a direct dependency of the otherwise banned crate + #{ crate = "ansi_term@0.11.0", wrappers = ["this-crate-directly-depends-on-ansi_term"] }, +] + +# List of features to allow/deny +# Each entry the name of a crate and a version range. If version is +# not specified, all versions will be matched. +#[[bans.features]] +#crate = "reqwest" +# Features to not allow +#deny = ["json"] +# Features to allow +#allow = [ +# "rustls", +# "__rustls", +# "__tls", +# "hyper-rustls", +# "rustls", +# "rustls-pemfile", +# "rustls-tls-webpki-roots", +# "tokio-rustls", +# "webpki-roots", +#] +# If true, the allowed features must exactly match the enabled feature set. If +# this is set there is no point setting `deny` +#exact = true + +# Certain crates/versions that will be skipped when doing duplicate detection. +skip = [ + { crate = "syn@2.0.119", reason = "Automerge tracing dependencies have not migrated to syn 3 yet" }, +] +# Similarly to `skip` allows you to skip certain crates during duplicate +# detection. Unlike skip, it also includes the entire tree of transitive +# dependencies starting at the specified crate, up to a certain depth, which is +# by default infinite. +skip-tree = [ + #"ansi_term@0.11.0", # will be skipped along with _all_ of its direct and transitive dependencies + #{ crate = "ansi_term@0.11.0", depth = 20 }, +] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +# Lint level for what to happen when a crate from a crate registry that is not +# in the allow list is encountered +unknown-registry = "deny" +# Lint level for what to happen when a crate from a git repository that is not +# in the allow list is encountered +unknown-git = "deny" +# List of URLs for allowed crate registries. Defaults to the crates.io index +# if not specified. If it is specified but empty, no registries are allowed. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# List of URLs for allowed Git repositories +allow-git = [] + +[sources.allow-org] +# github.com organizations to allow git sources for +github = [] +# gitlab.com organizations to allow git sources for +gitlab = [] +# bitbucket.org organizations to allow git sources for +bitbucket = [] diff --git a/pkg/automerge/internal/reference/wasm/src/lib.rs b/pkg/automerge/internal/reference/wasm/src/lib.rs new file mode 100644 index 0000000000..45093abaf8 --- /dev/null +++ b/pkg/automerge/internal/reference/wasm/src/lib.rs @@ -0,0 +1,2541 @@ +// 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::cell::RefCell; +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, ChangeHash, Cursor, MoveCursor, ObjId, ObjType, Patch, PatchAction, Prop, + ReadDoc, ScalarValue, Value, ROOT, +}; + +struct State { + doc: AutoCommit, + objects: Vec, + sync_states: Vec>, + output: Vec, + error: String, +} + +impl State { + fn new() -> Self { + Self { + doc: AutoCommit::new(), + objects: vec![ROOT], + sync_states: Vec::new(), + output: Vec::new(), + error: String::new(), + } + } + + fn reset(&mut self, doc: AutoCommit) { + self.doc = doc; + self.objects.clear(); + self.objects.push(ROOT); + self.sync_states.clear(); + self.output.clear(); + self.error.clear(); + } + + fn object(&self, handle: u32) -> Result { + self.objects + .get(handle as usize) + .cloned() + .ok_or_else(|| format!("invalid object handle {handle}")) + } + + fn push_object(&mut self, object: ObjId) -> Result { + let handle = + u32::try_from(self.objects.len()).map_err(|_| "too many object handles".to_owned())?; + self.objects.push(object); + Ok(handle) + } + + fn fail(&mut self, error: impl ToString) -> i32 { + self.error = error.to_string(); + -1 + } +} + +thread_local! { + static STATE: RefCell = RefCell::new(State::new()); +} + +#[no_mangle] +pub extern "C" fn am_abi_version() -> u32 { + 1 +} + +fn input_bytes(pointer: u32, length: u32) -> Vec { + if length == 0 { + return Vec::new(); + } + + // The caller allocates this memory through am_alloc and keeps it alive for + // the duration of the call. + unsafe { slice::from_raw_parts(pointer as *const u8, length as usize).to_vec() } +} + +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 { + return 0; + } + + let mut bytes = Vec::::with_capacity(length as usize); + let pointer = bytes.as_mut_ptr(); + mem::forget(bytes); + pointer as u32 +} + +#[no_mangle] +pub extern "C" fn am_free(pointer: u32, length: u32) { + if pointer == 0 || length == 0 { + return; + } + + unsafe { + drop(Vec::from_raw_parts(pointer as *mut u8, 0, length as usize)); + } +} + +#[no_mangle] +pub extern "C" fn am_output_len() -> u32 { + STATE.with(|state| state.borrow().output.len() as u32) +} + +#[no_mangle] +pub extern "C" fn am_output_copy(pointer: u32) -> i32 { + STATE.with(|state| { + let state = state.borrow(); + if !state.output.is_empty() && pointer == 0 { + return -1; + } + + unsafe { + ptr::copy_nonoverlapping( + state.output.as_ptr(), + pointer as *mut u8, + state.output.len(), + ); + } + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_error_len() -> u32 { + STATE.with(|state| state.borrow().error.len() as u32) +} + +#[no_mangle] +pub extern "C" fn am_error_copy(pointer: u32) -> i32 { + STATE.with(|state| { + let state = state.borrow(); + if !state.error.is_empty() && pointer == 0 { + return -1; + } + + unsafe { + ptr::copy_nonoverlapping(state.error.as_ptr(), pointer as *mut u8, state.error.len()); + } + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_create() -> i32 { + STATE.with(|state| state.borrow_mut().reset(AutoCommit::new())); + 0 +} + +#[no_mangle] +pub extern "C" fn am_load(pointer: u32, length: u32) -> i32 { + let bytes = input_bytes(pointer, length); + STATE.with(|state| { + let mut state = state.borrow_mut(); + match AutoCommit::load(&bytes) { + Ok(doc) => { + state.reset(doc); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[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| { + let mut state = state.borrow_mut(); + state.output = state.doc.save(); + state.error.clear(); + }); + 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); + if actor.is_empty() { + return STATE.with(|state| state.borrow_mut().fail("actor ID cannot be empty")); + } + + STATE.with(|state| { + let mut state = state.borrow_mut(); + state.doc.set_actor(ActorId::from(actor)); + state.error.clear(); + }); + 0 +} + +#[no_mangle] +pub extern "C" fn am_put_string( + 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_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.put(&object, key, value) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +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) => { + STATE.with(|state| state.borrow_mut().fail(error)); + 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(); + let object = match state.object(object_handle) { + Ok(object) => object, + Err(error) => { + state.fail(error); + return -1; + } + }; + match state.doc.put_object(&object, key, 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_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) => { + 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(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("object does not exist"); + -1 + } + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_insert_scalar( + 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.insert(&object, index, value) { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +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), + }; + 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 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +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), + }; + 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 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 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +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), + }; + 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(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +fn hydrate_map_to_json(map: &automerge::hydrate::Map) -> serde_json::Value { + serde_json::Value::Object( + map.iter() + .map(|(key, value)| (key.to_owned(), hydrate_value_to_json(&value.value))) + .collect(), + ) +} + +fn hydrate_value_to_json(value: &automerge::hydrate::Value) -> serde_json::Value { + match value { + automerge::hydrate::Value::Scalar(value) => scalar_to_json(value), + automerge::hydrate::Value::Map(value) => hydrate_map_to_json(value), + automerge::hydrate::Value::List(value) => serde_json::Value::Array( + value + .iter() + .map(|value| hydrate_value_to_json(&value.value)) + .collect(), + ), + automerge::hydrate::Value::Text(value) => serde_json::json!(value.to_string()), + } +} + +fn scalar_to_json(value: &automerge::ScalarValue) -> serde_json::Value { + match value { + automerge::ScalarValue::Bytes(value) => serde_json::json!(value), + automerge::ScalarValue::Str(value) => serde_json::json!(value), + automerge::ScalarValue::Int(value) => serde_json::json!(value), + automerge::ScalarValue::Uint(value) => serde_json::json!(value), + automerge::ScalarValue::F64(value) => serde_json::json!(value), + automerge::ScalarValue::Counter(value) => serde_json::json!(i64::from(value)), + automerge::ScalarValue::Timestamp(value) => serde_json::json!(value), + automerge::ScalarValue::Boolean(value) => serde_json::json!(value), + automerge::ScalarValue::Null => serde_json::Value::Null, + automerge::ScalarValue::Unknown { bytes, .. } => serde_json::json!(bytes), + } +} + +#[no_mangle] +pub extern "C" fn am_text_cursor_position(object_handle: u32, pointer: u32, length: u32) -> i64 { + let bytes = input_bytes(pointer, length); + let cursor = match Cursor::try_from(bytes) { + Ok(cursor) => cursor, + 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_cursor_position(&object, &cursor, None) { + Ok(position) => match i64::try_from(position) { + Ok(position) => { + state.error.clear(); + position + } + Err(error) => { + state.fail(error); + -1 + } + }, + Err(error) => { + state.fail(error); + -1 + } + } + }) +} + +#[no_mangle] +pub extern "C" fn am_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); + match state.doc.commit_with(options) { + Some(hash) => { + state.output = hash.as_ref().to_vec(); + state.error.clear(); + 0 + } + None => state.fail("change contains no operations"), + } + }) +} + +#[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| { + let mut state = state.borrow_mut(); + state.output = state + .doc + .get_heads() + .into_iter() + .flat_map(|hash| hash.0) + .collect(); + state.error.clear(); + }); + 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); + STATE.with(|state| { + let mut state = state.borrow_mut(); + let mut other = match AutoCommit::load(&bytes) { + Ok(other) => other, + Err(error) => return state.fail(error), + }; + match state.doc.merge(&mut other) { + Ok(heads) => { + state.output = heads.into_iter().flat_map(|hash| hash.0).collect(); + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_new() -> i64 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let handle = match u32::try_from(state.sync_states.len()) { + Ok(handle) => handle, + Err(_) => { + state.fail("too many sync states"); + return -1; + } + }; + state.sync_states.push(Some(SyncState::new())); + state.error.clear(); + i64::from(handle) + }) +} + +#[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| { + let mut state = state.borrow_mut(); + let Some(sync_state) = state.sync_states.get_mut(handle as usize) else { + return state.fail(format!("invalid sync state handle {handle}")); + }; + if sync_state.take().is_none() { + return state.fail(format!("sync state handle {handle} is closed")); + } + state.error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_generate(handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let State { + doc, + sync_states, + output, + error, + .. + } = &mut *state; + let Some(Some(sync_state)) = sync_states.get_mut(handle as usize) else { + return state.fail(format!("invalid sync state handle {handle}")); + }; + + *output = doc + .sync() + .generate_sync_message(sync_state) + .map(Message::encode) + .unwrap_or_default(); + error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_receive(handle: u32, pointer: u32, length: u32) -> i32 { + let bytes = input_bytes(pointer, length); + let message = match Message::decode(&bytes) { + Ok(message) => message, + Err(error) => return STATE.with(|state| state.borrow_mut().fail(error)), + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + if !matches!(state.sync_states.get(handle as usize), Some(Some(_))) { + return state.fail(format!("invalid sync state handle {handle}")); + } + + let result = { + let State { + doc, sync_states, .. + } = &mut *state; + let sync_state = sync_states[handle as usize].as_mut().unwrap(); + doc.sync().receive_sync_message(sync_state, message) + }; + match result { + Ok(()) => { + state.error.clear(); + 0 + } + Err(error) => state.fail(error), + } + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_save(handle: u32) -> i32 { + STATE.with(|state| { + let mut state = state.borrow_mut(); + let Some(Some(sync_state)) = state.sync_states.get(handle as usize) else { + return state.fail(format!("invalid sync state handle {handle}")); + }; + state.output = sync_state.encode(); + state.error.clear(); + 0 + }) +} + +#[no_mangle] +pub extern "C" fn am_sync_load(pointer: u32, length: u32) -> i64 { + let bytes = input_bytes(pointer, length); + let sync_state = match SyncState::decode(&bytes) { + Ok(sync_state) => sync_state, + Err(error) => { + STATE.with(|state| state.borrow_mut().fail(error)); + return -1; + } + }; + + STATE.with(|state| { + let mut state = state.borrow_mut(); + let handle = match u32::try_from(state.sync_states.len()) { + Ok(handle) => handle, + Err(_) => { + state.fail("too many sync states"); + return -1; + } + }; + state.sync_states.push(Some(sync_state)); + state.error.clear(); + i64::from(handle) + }) +} diff --git a/pkg/automerge/internal/storage/columns.go b/pkg/automerge/internal/storage/columns.go new file mode 100644 index 0000000000..d6ac719c49 --- /dev/null +++ b/pkg/automerge/internal/storage/columns.go @@ -0,0 +1,670 @@ +// 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 storage + +import ( + "bytes" + "compress/flate" + "encoding/binary" + "fmt" + "io" + "math" + "slices" + "unicode/utf8" +) + +type ( + reader struct { + data []byte + offset int + } + + columnMeta struct { + specification uint32 + normalized uint32 + length uint64 + compressed bool + } + + column struct { + specification uint32 + data []byte + } + + optional[T any] struct { + value T + valid bool + } +) + +const ( + maxDecodedItems = 100_000_000 + maxInflatedBytes = 512 << 20 +) + +func (r *reader) remaining() int { + return len(r.data) - r.offset +} + +func (r *reader) bytes(length uint64) ([]byte, error) { + if length > uint64(r.remaining()) { + return nil, fmt.Errorf( + "need %d bytes at offset %d, only %d remain", + length, + r.offset, + r.remaining(), + ) + } + + if length > uint64(math.MaxInt) { + return nil, fmt.Errorf("byte length %d exceeds platform capacity", length) + } + + start := r.offset + r.offset += int(length) + + return r.data[start:r.offset], nil +} + +func (r *reader) byte() (byte, error) { + value, err := r.bytes(1) + if err != nil { + return 0, err + } + + return value[0], nil +} + +func (r *reader) uleb() (uint64, error) { + start := r.offset + + var value uint64 + + for i := range 10 { + current, err := r.byte() + if err != nil { + return 0, fmt.Errorf("cannot decode uLEB at offset %d: %w", start, err) + } + + payload := uint64(current & 0x7f) + if i == 9 && payload > 1 { + return 0, fmt.Errorf("uLEB at offset %d overflows uint64", start) + } + + value |= payload << (7 * i) + if current&0x80 == 0 { + if i > 0 && payload == 0 { + return 0, fmt.Errorf("uLEB at offset %d is not minimally encoded", start) + } + + return value, nil + } + } + + return 0, fmt.Errorf("uLEB at offset %d exceeds 10 bytes", start) +} + +func (r *reader) leb() (int64, error) { + start := r.offset + + var ( + value uint64 + current byte + shift uint + ) + + for i := range 10 { + var err error + + current, err = r.byte() + if err != nil { + return 0, fmt.Errorf("cannot decode LEB at offset %d: %w", start, err) + } + + payload := uint64(current & 0x7f) + if i == 9 { + if payload != 0 && payload != 0x7f { + return 0, fmt.Errorf("LEB at offset %d overflows int64", start) + } + + if payload == 0 && current&0x40 != 0 { + return 0, fmt.Errorf("LEB at offset %d overflows int64", start) + } + + if payload == 0x7f && current&0x40 == 0 { + return 0, fmt.Errorf("LEB at offset %d overflows int64", start) + } + } + + value |= payload << shift + shift += 7 + + if current&0x80 == 0 { + if i > 0 { + previous := r.data[r.offset-2] + if current == 0 && previous&0x40 == 0 { + return 0, fmt.Errorf("LEB at offset %d is not minimally encoded", start) + } + + if current == 0x7f && previous&0x40 != 0 { + return 0, fmt.Errorf("LEB at offset %d is not minimally encoded", start) + } + } + + if shift < 64 && current&0x40 != 0 { + value |= ^uint64(0) << shift + } + + return int64(value), nil + } + } + + return 0, fmt.Errorf("LEB at offset %d exceeds 10 bytes", start) +} + +func decodeRLE[T any](data []byte, decodeValue func(*reader) (T, error)) ([]optional[T], error) { + r := &reader{data: data} + values := make([]optional[T], 0) + + for r.remaining() > 0 { + run, err := r.leb() + if err != nil { + return nil, fmt.Errorf("cannot decode run length: %w", err) + } + + switch { + case run > 0: + value, err := decodeValue(r) + if err != nil { + return nil, fmt.Errorf("cannot decode repeated value: %w", err) + } + + if err := appendRepeated(&values, optional[T]{value: value, valid: true}, uint64(run)); err != nil { + return nil, err + } + case run == 0: + count, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot decode null run length: %w", err) + } + + if count == 0 { + return nil, fmt.Errorf("null run cannot be empty") + } + + if err := appendRepeated(&values, optional[T]{}, count); err != nil { + return nil, err + } + default: + if run == math.MinInt64 { + return nil, fmt.Errorf("literal run length overflows") + } + + count := uint64(-run) + if err := reserveItems(len(values), count); err != nil { + return nil, err + } + + values = slices.Grow(values, int(count)) + + for range count { + value, err := decodeValue(r) + if err != nil { + return nil, fmt.Errorf("cannot decode literal value: %w", err) + } + + values = append(values, optional[T]{value: value, valid: true}) + } + } + } + + return values, nil +} + +func appendRepeated[T any](values *[]optional[T], value optional[T], count uint64) error { + if err := reserveItems(len(*values), count); err != nil { + return err + } + + // Grow once for the whole run. A long run (an entire column of the same + // value, common for text where every operation shares an object and action) + // otherwise reallocated the slice repeatedly as it doubled. + grown := slices.Grow(*values, int(count)) + for range count { + grown = append(grown, value) + } + + *values = grown + + return nil +} + +func reserveItems(existing int, additional uint64) error { + if additional > maxDecodedItems || uint64(existing)+additional > maxDecodedItems { + return fmt.Errorf("decoded column exceeds %d items", maxDecodedItems) + } + + return nil +} + +func decodeULEBColumn(data []byte) ([]optional[uint64], error) { + return decodeRLE(data, func(r *reader) (uint64, error) { + return r.uleb() + }) +} + +func decodeDeltaColumn(data []byte) ([]optional[uint64], error) { + deltas, err := decodeRLE(data, func(r *reader) (int64, error) { + return r.leb() + }) + if err != nil { + return nil, err + } + + values := make([]optional[uint64], len(deltas)) + + var previous uint64 + + for i, delta := range deltas { + if !delta.valid { + continue + } + + next, err := addSigned(previous, delta.value) + if err != nil { + return nil, fmt.Errorf("delta item %d: %w", i, err) + } + + values[i] = optional[uint64]{value: next, valid: true} + previous = next + } + + return values, nil +} + +func decodeSignedDeltaColumn(data []byte) ([]optional[int64], error) { + deltas, err := decodeRLE(data, func(r *reader) (int64, error) { + return r.leb() + }) + if err != nil { + return nil, err + } + + values := make([]optional[int64], len(deltas)) + + var previous int64 + + for i, delta := range deltas { + if !delta.valid { + continue + } + + if delta.value > 0 && previous > math.MaxInt64-delta.value { + return nil, fmt.Errorf("delta item %d overflows int64", i) + } + + if delta.value < 0 && previous < math.MinInt64-delta.value { + return nil, fmt.Errorf("delta item %d underflows int64", i) + } + + previous += delta.value + values[i] = optional[int64]{value: previous, valid: true} + } + + return values, nil +} + +func addSigned(value uint64, delta int64) (uint64, error) { + if delta >= 0 { + addition := uint64(delta) + if value > math.MaxUint64-addition { + return 0, fmt.Errorf("positive delta overflows uint64") + } + + return value + addition, nil + } + + if delta == math.MinInt64 { + subtraction := uint64(math.MaxInt64) + 1 + if subtraction > value { + return 0, fmt.Errorf("negative delta underflows uint64") + } + + return value - subtraction, nil + } + + subtraction := uint64(-delta) + if subtraction > value { + return 0, fmt.Errorf("negative delta underflows uint64") + } + + return value - subtraction, nil +} + +func decodeStringColumn(data []byte) ([]optional[string], error) { + return decodeRLE(data, func(r *reader) (string, error) { + length, err := r.uleb() + if err != nil { + return "", err + } + + value, err := r.bytes(length) + if err != nil { + return "", err + } + + if !utf8.Valid(value) { + return "", fmt.Errorf("string is not valid UTF-8") + } + + return string(value), nil + }) +} + +func decodeBooleanColumn(data []byte, expected int) ([]bool, error) { + r := &reader{data: data} + values := make([]bool, 0, expected) + current := false + + for r.remaining() > 0 { + count, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot decode boolean run: %w", err) + } + + if err := reserveItems(len(values), count); err != nil { + return nil, err + } + + for range count { + values = append(values, current) + } + + current = !current + } + + if len(values) != expected { + return nil, fmt.Errorf("boolean column has %d items, expected %d", len(values), expected) + } + + return values, nil +} + +func parseColumnMetadata(r *reader, allowCompressed bool) ([]columnMeta, error) { + count, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot decode column count: %w", err) + } + + if count > maxDecodedItems { + return nil, fmt.Errorf("column count %d exceeds limit", count) + } + + metadata := make([]columnMeta, 0, count) + + var previous uint32 + + for i := range count { + rawSpec, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot decode column %d specification: %w", i, err) + } + + if rawSpec > math.MaxUint32 { + return nil, fmt.Errorf("column %d specification %d exceeds uint32", i, rawSpec) + } + + specification := uint32(rawSpec) + compressed := specification&8 != 0 + normalized := specification &^ 8 + + if compressed && !allowCompressed { + return nil, fmt.Errorf("column %d is compressed in a change chunk", i) + } + + if i > 0 && normalized <= previous { + return nil, fmt.Errorf("column %d specification %d is not strictly sorted", i, normalized) + } + + previous = normalized + + length, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot decode column %d length: %w", i, err) + } + + metadata = append( + metadata, + columnMeta{ + specification: specification, + normalized: normalized, + length: length, + compressed: compressed, + }, + ) + } + + return metadata, nil +} + +func readColumns(r *reader, metadata []columnMeta) (map[uint32]column, error) { + columns := make(map[uint32]column, len(metadata)) + for i, meta := range metadata { + data, err := r.bytes(meta.length) + if err != nil { + return nil, fmt.Errorf("cannot read column %d: %w", i, err) + } + + if meta.compressed { + data, err = inflate(data) + if err != nil { + return nil, fmt.Errorf("cannot inflate column %d: %w", i, err) + } + } + + columns[meta.normalized] = column{ + specification: meta.specification, + data: append([]byte(nil), data...), + } + } + + 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() }() + + output, err := io.ReadAll(io.LimitReader(compressed, maxInflatedBytes+1)) + if err != nil { + return nil, fmt.Errorf("cannot read DEFLATE stream: %w", err) + } + + if len(output) > maxInflatedBytes { + return nil, fmt.Errorf("inflated data exceeds %d bytes", maxInflatedBytes) + } + + return output, nil +} + +func decodeScalars(metaData, rawData []byte, expected int) ([]optional[Scalar], error) { + metadata, err := decodeULEBColumn(metaData) + if err != nil { + return nil, fmt.Errorf("cannot decode value metadata: %w", err) + } + + if len(metadata) != expected { + return nil, fmt.Errorf("value metadata has %d items, expected %d", len(metadata), expected) + } + + raw := &reader{data: rawData} + values := make([]optional[Scalar], expected) + + for i, item := range metadata { + if !item.valid { + continue + } + + scalarType := ScalarType(item.value & 0x0f) + length := item.value >> 4 + + valueBytes, err := raw.bytes(length) + if err != nil { + return nil, fmt.Errorf("cannot read scalar %d: %w", i, err) + } + + scalar, err := decodeScalar(scalarType, valueBytes) + if err != nil { + return nil, fmt.Errorf("cannot decode scalar %d: %w", i, err) + } + + values[i] = optional[Scalar]{value: scalar, valid: true} + } + + if raw.remaining() != 0 { + return nil, fmt.Errorf("value column has %d trailing bytes", raw.remaining()) + } + + return values, nil +} + +func decodeScalar(scalarType ScalarType, data []byte) (Scalar, error) { + scalar := Scalar{Type: scalarType} + switch scalarType { + case ScalarNull: + if len(data) != 0 { + return Scalar{}, fmt.Errorf("null has length %d, expected 0", len(data)) + } + case ScalarFalse, ScalarTrue: + if len(data) != 0 { + return Scalar{}, fmt.Errorf("boolean has length %d, expected 0", len(data)) + } + + scalar.Bool = scalarType == ScalarTrue + case ScalarUint: + r := &reader{data: data} + + value, err := r.uleb() + if err != nil || r.remaining() != 0 { + return Scalar{}, fmt.Errorf("invalid unsigned integer scalar") + } + + scalar.Uint = value + case ScalarInt, ScalarCounter, ScalarTimestamp: + r := &reader{data: data} + + value, err := r.leb() + if err != nil || r.remaining() != 0 { + return Scalar{}, fmt.Errorf("invalid signed integer scalar") + } + + scalar.Int = value + case ScalarFloat64: + if len(data) != 8 { + return Scalar{}, fmt.Errorf("float has length %d, expected 8", len(data)) + } + + scalar.Float = math.Float64frombits(binary.LittleEndian.Uint64(data)) + case ScalarString: + if !utf8.Valid(data) { + return Scalar{}, fmt.Errorf("string scalar is not valid UTF-8") + } + + scalar.String = string(data) + case ScalarBytes: + scalar.Bytes = append([]byte(nil), data...) + default: + scalar.Raw = append([]byte(nil), data...) + } + + return scalar, nil +} + +func requireColumn(columns map[uint32]column, specification uint32) ([]byte, error) { + value, ok := columns[specification] + if !ok { + return nil, fmt.Errorf("required column %d is missing", specification) + } + + delete(columns, specification) + + return value.data, nil +} + +func optionalColumn(columns map[uint32]column, specification uint32) []byte { + value, ok := columns[specification] + if !ok { + return nil + } + + delete(columns, specification) + + return value.data +} + +func requireItems[T any](name string, values []optional[T], expected int, nullable bool) error { + if len(values) != expected { + return fmt.Errorf("%s column has %d items, expected %d", name, len(values), expected) + } + + if nullable { + return nil + } + + for i, value := range values { + if !value.valid { + return fmt.Errorf("%s column item %d is null", name, i) + } + } + + return nil +} + +func copyHash(data []byte) ChangeHash { + var hash ChangeHash + copy(hash[:], data) + + return hash +} + +// Deflate compresses an Automerge change body using the wire-format codec. +func Deflate(data []byte) ([]byte, error) { return deflate(data) } diff --git a/pkg/automerge/internal/storage/decode.go b/pkg/automerge/internal/storage/decode.go new file mode 100644 index 0000000000..c9e0db2b55 --- /dev/null +++ b/pkg/automerge/internal/storage/decode.go @@ -0,0 +1,935 @@ +// 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 storage + +import ( + "bytes" + "crypto/sha256" + "fmt" + "math" + "slices" + "unicode/utf8" +) + +var magic = [4]byte{0x85, 0x6f, 0x4a, 0x83} + +type decodedChunk struct { + kind ChunkType + content []byte + hash *ChangeHash + raw []byte +} + +type implicitOperationIDs struct { + actorIndex uint64 + startOp uint64 +} + +// Decode parses all chunks in data and validates the resulting dependency +// graph. It accepts document, change, and compressed change chunks. +func Decode(data []byte) (*Document, error) { + return decode(data, true) +} + +// DecodePartial parses chunks whose causal dependencies may already exist in +// another document. Column and operation ownership validation still happens +// while whole-history frontier validation is deferred to the caller. +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") + } + + r := &reader{data: data} + document := &Document{} + + for r.remaining() > 0 { + chunk, err := decodeChunk(r) + if err != nil { + return nil, fmt.Errorf("cannot decode chunk %d: %w", len(document.ChunkTypes), err) + } + + document.ChunkTypes = append(document.ChunkTypes, chunk.kind) + + switch chunk.kind { + case ChunkDocument: + if len(document.ChunkTypes) != 1 { + return nil, fmt.Errorf("only the first chunk may be a document chunk") + } + + if err := decodeDocumentChunk(document, chunk.content); err != nil { + return nil, fmt.Errorf("cannot decode document chunk: %w", err) + } + case ChunkChange, ChunkCompressedChange: + change, actors, unknown, err := decodeChangeChunk(chunk.content, *chunk.hash) + if err != nil { + return nil, fmt.Errorf("cannot decode change chunk: %w", err) + } + + change.Raw = append([]byte(nil), chunk.raw...) + + document.Changes = append(document.Changes, change) + document.Actors = mergeActors(document.Actors, actors) + document.UnknownColumns = append(document.UnknownColumns, unknown...) + default: + return nil, fmt.Errorf("unsupported chunk type %d", chunk.kind) + } + } + + if validateHistory { + if err := validateDocument(document); err != nil { + return nil, fmt.Errorf("invalid dependency graph: %w", err) + } + } + + return document, nil +} + +func decodeChunk(r *reader) (decodedChunk, error) { + start := r.offset + + header, err := r.bytes(4) + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot read magic bytes: %w", err) + } + + if !bytes.Equal(header, magic[:]) { + return decodedChunk{}, fmt.Errorf("invalid magic bytes %x", header) + } + + checksum, err := r.bytes(4) + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot read checksum: %w", err) + } + + rawType, err := r.byte() + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot read chunk type: %w", err) + } + + kind := ChunkType(rawType) + if kind > ChunkCompressedChange { + return decodedChunk{}, fmt.Errorf("unknown chunk type %d", kind) + } + + lengthStart := r.offset + + length, err := r.uleb() + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot read chunk length: %w", err) + } + + lengthBytes := append([]byte(nil), r.data[lengthStart:r.offset]...) + + content, err := r.bytes(length) + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot read chunk content: %w", err) + } + + hashInput := make([]byte, 0, 1+len(lengthBytes)+len(content)) + hashKind := kind + hashContent := content + + if kind == ChunkCompressedChange { + hashKind = ChunkChange + + hashContent, err = inflate(content) + if err != nil { + return decodedChunk{}, fmt.Errorf("cannot inflate compressed change: %w", err) + } + + lengthBytes = appendULEB(nil, uint64(len(hashContent))) + } + + hashInput = append(hashInput, byte(hashKind)) + hashInput = append(hashInput, lengthBytes...) + hashInput = append(hashInput, hashContent...) + + digest := sha256.Sum256(hashInput) + if !bytes.Equal(checksum, digest[:4]) { + return decodedChunk{}, fmt.Errorf( + "checksum mismatch: encoded %x, calculated %x", + checksum, + digest[:4], + ) + } + + chunk := decodedChunk{ + kind: kind, + content: hashContent, + raw: append([]byte(nil), r.data[start:r.offset]...), + } + if kind == ChunkChange || kind == ChunkCompressedChange { + hash := ChangeHash(digest) + chunk.hash = &hash + } + + return chunk, nil +} + +func appendULEB(destination []byte, value uint64) []byte { + for { + current := byte(value & 0x7f) + + value >>= 7 + if value != 0 { + current |= 0x80 + } + + destination = append(destination, current) + if value == 0 { + return destination + } + } +} + +func decodeDocumentChunk(document *Document, data []byte) error { + r := &reader{data: data} + + actors, err := decodeActorArray(r, true) + if err != nil { + return fmt.Errorf("cannot decode actors: %w", err) + } + + heads, err := decodeHashArray(r, true) + if err != nil { + return fmt.Errorf("cannot decode heads: %w", err) + } + + changeMetadata, err := parseColumnMetadata(r, true) + if err != nil { + return fmt.Errorf("cannot decode change column metadata: %w", err) + } + + operationMetadata, err := parseColumnMetadata(r, true) + if err != nil { + return fmt.Errorf("cannot decode operation column metadata: %w", err) + } + + changeColumns, err := readColumns(r, changeMetadata) + if err != nil { + return fmt.Errorf("cannot decode change columns: %w", err) + } + + operationColumns, err := readColumns(r, operationMetadata) + if err != nil { + return fmt.Errorf("cannot decode operation columns: %w", err) + } + + changes, unknownChanges, err := decodeDocumentChanges(changeColumns, actors) + if err != nil { + return fmt.Errorf("cannot decode changes: %w", err) + } + + operations, unknownOperations, err := decodeOperations(operationColumns, actors, false, nil) + if err != nil { + return fmt.Errorf("cannot decode operations: %w", err) + } + + operations, err = restoreChangeOperations(operations) + if err != nil { + return fmt.Errorf("cannot restore change operations: %w", err) + } + + if err := assignOperations(changes, operations); err != nil { + return fmt.Errorf("cannot assign operations: %w", err) + } + + headIndexes := make([]uint64, len(heads)) + for i := range heads { + index, err := r.uleb() + if err != nil { + return fmt.Errorf("cannot decode head index %d: %w", i, err) + } + + if index >= uint64(len(changes)) { + return fmt.Errorf("head index %d is out of bounds", index) + } + + headIndexes[i] = index + + hash := heads[i] + if changes[index].Hash != nil && *changes[index].Hash != hash { + return fmt.Errorf("head index %d is assigned conflicting hashes", index) + } + + changes[index].Hash = &hash + } + + if r.remaining() != 0 { + return fmt.Errorf("document chunk has %d trailing bytes", r.remaining()) + } + + if err := validateSnapshotGraph(changes, headIndexes); err != nil { + return err + } + + if err := reconstructSnapshotChanges(changes); err != nil { + return err + } + + document.Actors = actors + document.Heads = heads + document.Changes = changes + + document.UnknownColumns = append(unknownChanges, unknownOperations...) + + return nil +} + +// restoreChangeOperations turns a snapshot's operation view back into the one a +// change carries. A snapshot records, for every surviving operation, the +// operations that superseded it, and it drops delete operations entirely because +// they survive only as those successor entries. A change instead names each +// operation's predecessors and spells its deletes out, so rebuilding a change +// means inverting the successor lists and recreating the deletes they imply. +// +// Successors are left in place: the engine materializes a loaded snapshot from +// them, and re-encoding a change only ever reads predecessors. +func restoreChangeOperations(operations []Operation) ([]Operation, error) { + stored := make(map[OpID]struct{}, len(operations)) + for _, operation := range operations { + stored[operation.ID] = struct{}{} + } + + predecessors := make(map[OpID][]OpID) + for _, operation := range operations { + for _, successor := range operation.Successors { + predecessors[successor] = append(predecessors[successor], operation.ID) + } + } + + for identifier := range predecessors { + slices.SortFunc(predecessors[identifier], func(left, right OpID) int { + return left.Compare(right) + }) + } + + for i := range operations { + operations[i].Predecessors = predecessors[operations[i].ID] + } + + deletes := make([]Operation, 0) + + for identifier, superseded := range predecessors { + if _, ok := stored[identifier]; ok { + continue + } + + // Only a delete leaves no operation of its own behind, and every operation + // it removed shares the object and key it targeted. + source := operationByID(operations, superseded[0]) + if source == nil { + return nil, fmt.Errorf( + "operation %s@%d supersedes nothing that the snapshot retains", + identifier.Actor, + identifier.Counter, + ) + } + + deletes = append(deletes, Operation{ + ID: identifier, + Object: source.Object, + Key: supersededKey(source), + Action: ActionDelete, + Predecessors: superseded, + }) + } + + slices.SortFunc(deletes, func(left, right Operation) int { + return left.ID.Compare(right.ID) + }) + + return append(operations, deletes...), nil +} + +// supersededKey names the location an operation occupies, which is what an +// operation superseding it addresses. A map operation is addressed by its +// property, while a sequence operation is addressed by the element identifier: +// an insertion creates the element it is named by, and any later operation on +// that element already carries it. +func supersededKey(operation *Operation) Key { + if operation.Key.Property != nil || !operation.Insert { + return operation.Key + } + + element := operation.ID + + return Key{Element: &element} +} + +func operationByID(operations []Operation, identifier OpID) *Operation { + for i := range operations { + if operations[i].ID == identifier { + return &operations[i] + } + } + + return nil +} + +// reconstructSnapshotChanges restores the change-chunk identity of every change +// in a document chunk. Snapshots store the frontier hashes only and reference +// ancestry by column index, so every non-head change decodes without a hash and +// without its original bytes. Re-encoding each change once its dependencies are +// known recovers both, which keeps the change graph whole: without it only the +// heads are addressable and any walk of their ancestry hits a missing change. +// +// Dependencies are rebuilt in the stored index order because the encoder writes +// dependency hashes in slice order, so that order is what the original hash was +// computed over. +func reconstructSnapshotChanges(changes []Change) error { + resolved := make([]bool, len(changes)) + remaining := len(changes) + + for remaining > 0 { + progressed := false + + for i := range changes { + change := &changes[i] + + if resolved[i] || !dependenciesResolved(change, resolved) { + continue + } + + change.Dependencies = make([]ChangeHash, 0, len(change.DependencyIndexes)) + for _, index := range change.DependencyIndexes { + change.Dependencies = append(change.Dependencies, *changes[index].Hash) + } + + recorded := change.Hash + change.Hash = nil + + if _, err := EncodeChange(change); err != nil { + return fmt.Errorf("cannot rebuild snapshot change %d: %w", i, err) + } + + // A recorded hash only exists for frontier changes. Disagreeing with it + // means the rebuilt bytes are not the ones the writer hashed, so the + // whole graph would be keyed by identifiers no peer shares. + if recorded != nil && *recorded != *change.Hash { + return fmt.Errorf( + "snapshot change %d rebuilds to hash %s but the document records %s", + i, + change.Hash, + recorded, + ) + } + + resolved[i] = true + remaining-- + progressed = true + } + + if !progressed { + return fmt.Errorf("snapshot dependency graph cannot be ordered") + } + } + + return nil +} + +func dependenciesResolved(change *Change, resolved []bool) bool { + for _, index := range change.DependencyIndexes { + if !resolved[index] { + return false + } + } + + return true +} + +func decodeDocumentChanges( + columns map[uint32]column, + actors []ActorID, +) ([]Change, []RawColumn, error) { + if len(columns) == 0 { + return nil, nil, nil + } + + actorData, err := requireColumn(columns, 1) + if err != nil { + return nil, nil, err + } + + actorIndexes, err := decodeULEBColumn(actorData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode actor column: %w", err) + } + + count := len(actorIndexes) + if err := requireItems("actor", actorIndexes, count, false); err != nil { + return nil, nil, err + } + + sequence, err := decodeRequiredDelta(columns, 3, "sequence", count) + if err != nil { + return nil, nil, err + } + + maxOps, err := decodeRequiredDelta(columns, 19, "maxOp", count) + if err != nil { + return nil, nil, err + } + + times, err := decodeOptionalSignedDelta(columns, 35, "time", count) + if err != nil { + return nil, nil, err + } + + messages, err := decodeOptionalStrings(columns, 53, "message", count) + if err != nil { + return nil, nil, err + } + + groupData, err := requireColumn(columns, 64) + if err != nil { + return nil, nil, err + } + + groups, err := decodeULEBColumn(groupData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode dependency groups: %w", err) + } + + if err := requireItems("dependency group", groups, count, false); err != nil { + return nil, nil, err + } + + dependencyCount, err := sumGroups(groups) + if err != nil { + return nil, nil, err + } + + dependencies := make([]optional[uint64], 0) + + if dependencyCount > 0 { + dependencyData, err := requireColumn(columns, 67) + if err != nil { + return nil, nil, err + } + + dependencies, err = decodeDeltaColumn(dependencyData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode dependencies: %w", err) + } + + if err := requireItems("dependency", dependencies, dependencyCount, false); err != nil { + return nil, nil, err + } + } + + extras, err := decodeOptionalScalars(columns, 86, 87, count) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode change extras: %w", err) + } + + changes := make([]Change, count) + dependencyOffset := 0 + + for i := range changes { + actorIndex := actorIndexes[i].value + if actorIndex >= uint64(len(actors)) { + return nil, nil, fmt.Errorf("change %d actor index %d is out of bounds", i, actorIndex) + } + + changes[i] = Change{ + Actor: actors[actorIndex], + Sequence: sequence[i].value, + MaxOp: maxOps[i].value, + } + if times[i].valid { + changes[i].Time = times[i].value + } + + if messages[i].valid { + changes[i].Message = messages[i].value + } + + if extras[i].valid { + extra := extras[i].value + changes[i].Extra = &extra + } + + groupLength := int(groups[i].value) + + changes[i].DependencyIndexes = make([]uint64, groupLength) + for j := range groupLength { + changes[i].DependencyIndexes[j] = dependencies[dependencyOffset+j].value + } + + dependencyOffset += groupLength + } + + unknown := collectUnknown(columns) + + return changes, unknown, nil +} + +func decodeChangeChunk( + data []byte, + hash ChangeHash, +) (Change, []ActorID, []RawColumn, error) { + r := &reader{data: data} + + dependencies, err := decodeHashArray(r, false) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode dependencies: %w", err) + } + + actorBytes, err := decodeLengthPrefixed(r) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode actor: %w", err) + } + + actor, err := NewActorID(actorBytes) + if err != nil { + return Change{}, nil, nil, err + } + + sequence, err := r.uleb() + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode sequence: %w", err) + } + + if sequence == 0 { + return Change{}, nil, nil, fmt.Errorf("sequence is zero") + } + + startOp, err := r.uleb() + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode start op: %w", err) + } + + if startOp == 0 { + return Change{}, nil, nil, fmt.Errorf("start op is zero") + } + + timestamp, err := r.leb() + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode time: %w", err) + } + + messageBytes, err := decodeLengthPrefixed(r) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode message: %w", err) + } + + if !utf8.Valid(messageBytes) { + return Change{}, nil, nil, fmt.Errorf("message is not valid UTF-8") + } + + otherActors, err := decodeActorArray(r, true) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode other actors: %w", err) + } + + if slices.Contains(otherActors, actor) { + return Change{}, nil, nil, fmt.Errorf("other actors contains the change actor") + } + + metadata, err := parseColumnMetadata(r, false) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode operation metadata: %w", err) + } + + columns, err := readColumns(r, metadata) + if err != nil { + return Change{}, nil, nil, fmt.Errorf("cannot decode operation columns: %w", err) + } + + actors := append([]ActorID{actor}, otherActors...) + + operations, unknown, err := decodeOperations( + columns, + actors, + true, + &implicitOperationIDs{actorIndex: 0, startOp: startOp}, + ) + if err != nil { + return Change{}, nil, nil, err + } + + 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") + } + + maxOp = startOp + uint64(len(operations)) - 1 + } + + for i, operation := range operations { + expected := startOp + uint64(i) + if operation.ID.Actor != actor || operation.ID.Counter != expected { + return Change{}, nil, nil, fmt.Errorf( + "operation %d has ID %s@%d, expected %s@%d", + i, + operation.ID.Actor, + operation.ID.Counter, + actor, + expected, + ) + } + } + + change := Change{ + Hash: new(hash), + Actor: actor, + Sequence: sequence, + StartOp: startOp, + MaxOp: maxOp, + Time: timestamp, + Message: string(messageBytes), + Dependencies: dependencies, + Operations: operations, + } + if r.remaining() > 0 { + change.ExtraBytes = append([]byte(nil), r.data[r.offset:]...) + } + + return change, actors, unknown, nil +} + +func decodeOperations( + columns map[uint32]column, + actors []ActorID, + changeChunk bool, + implicitIDs *implicitOperationIDs, +) ([]Operation, []RawColumn, error) { + if len(columns) == 0 { + return nil, nil, nil + } + + actionData, err := requireColumn(columns, 66) + if err != nil { + return nil, nil, err + } + + actions, err := decodeULEBColumn(actionData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode actions: %w", err) + } + + count := len(actions) + if err := requireItems("action", actions, count, false); err != nil { + return nil, nil, err + } + + idActors := make([]optional[uint64], count) + if idActorData := optionalColumn(columns, 33); idActorData != nil { + idActors, err = decodeULEBColumn(idActorData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode operation actors: %w", err) + } + + if err := requireItems("operation actor", idActors, count, false); err != nil { + return nil, nil, err + } + } else if implicitIDs != nil { + for i := range idActors { + idActors[i] = optional[uint64]{value: implicitIDs.actorIndex, valid: true} + } + } else { + return nil, nil, fmt.Errorf("required column 33 is missing") + } + + idCounters := make([]optional[uint64], count) + if idCounterData := optionalColumn(columns, 35); idCounterData != nil { + idCounters, err = decodeDeltaColumn(idCounterData) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode operation counters: %w", err) + } + + if err := requireItems("operation counter", idCounters, count, false); err != nil { + return nil, nil, err + } + } else if implicitIDs != nil { + for i := range idCounters { + if implicitIDs.startOp > math.MaxUint64-uint64(i) { + return nil, nil, fmt.Errorf("implicit operation counter overflows uint64") + } + + idCounters[i] = optional[uint64]{ + value: implicitIDs.startOp + uint64(i), + valid: true, + } + } + } else { + return nil, nil, fmt.Errorf("required column 35 is missing") + } + + objectActors, err := decodeOptionalULEB(columns, 1, "object actor", count) + if err != nil { + return nil, nil, err + } + + objectCounters, err := decodeOptionalULEB(columns, 2, "object counter", count) + if err != nil { + return nil, nil, err + } + + keyActors, err := decodeOptionalULEB(columns, 17, "key actor", count) + if err != nil { + return nil, nil, err + } + + keyCounters, err := decodeOptionalDelta(columns, 19, "key counter", count) + if err != nil { + return nil, nil, err + } + + keyStrings, err := decodeOptionalStrings(columns, 21, "key string", count) + if err != nil { + return nil, nil, err + } + + inserts := make([]bool, count) + if insertData := optionalColumn(columns, 52); insertData != nil { + inserts, err = decodeBooleanColumn(insertData, count) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode insert column: %w", err) + } + } + + values, err := decodeOptionalScalars(columns, 86, 87, count) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode values: %w", err) + } + + var related [][]OpID + if changeChunk { + related, err = decodeGroupedOpIDs(columns, actors, 112, 113, 115, count, "predecessor") + } else { + related, err = decodeGroupedOpIDs(columns, actors, 128, 129, 131, count, "successor") + } + + if err != nil { + return nil, nil, err + } + + markExpand, err := decodeOptionalBooleans(columns, 148, count) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode mark expand: %w", err) + } + + markNames, err := decodeOptionalStrings(columns, 165, "mark name", count) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode mark name: %w", err) + } + + operations := make([]Operation, count) + for i := range operations { + id, err := opIDFromIndexes(idActors[i], idCounters[i], actors) + if err != nil { + return nil, nil, fmt.Errorf("operation %d ID: %w", i, err) + } + + object, err := objectIDFromIndexes(objectActors[i], objectCounters[i], actors) + if err != nil { + return nil, nil, fmt.Errorf("operation %d object: %w", i, err) + } + + key, err := keyFromColumns( + keyActors[i], + keyCounters[i], + keyStrings[i], + actors, + inserts[i], + ) + if err != nil { + return nil, nil, fmt.Errorf( + "operation %d key (insert=%t, action=%d): %w", + i, + inserts[i], + actions[i].value, + err, + ) + } + + operations[i] = Operation{ + ID: id, + Object: object, + Key: key, + Insert: inserts[i], + Action: Action(actions[i].value), + } + if values[i].valid { + value := values[i].value + operations[i].Value = &value + } + + if changeChunk { + operations[i].Predecessors = related[i] + } else { + operations[i].Successors = related[i] + if operations[i].Action == ActionDelete { + return nil, nil, fmt.Errorf("document operation %d explicitly encodes a delete", i) + } + } + + // Expand is a property of a mark, and its column is dense because booleans + // cannot be null. A document chunk shares one column across every change, + // so keeping the flag on ordinary operations would make a change that never + // carried an expand column re-encode with one and hash differently. + if markExpand[i].valid && operations[i].Action == ActionMark { + value := markExpand[i].value + operations[i].MarkExpand = &value + } + + if markNames[i].valid { + value := markNames[i].value + operations[i].MarkName = &value + } + } + + return operations, collectUnknown(columns), nil +} diff --git a/pkg/automerge/internal/storage/decode_test.go b/pkg/automerge/internal/storage/decode_test.go new file mode 100644 index 0000000000..4cdc1573af --- /dev/null +++ b/pkg/automerge/internal/storage/decode_test.go @@ -0,0 +1,388 @@ +// 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 storage + +import ( + "bytes" + "compress/flate" + "context" + "encoding/base64" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.probo.inc/probo/pkg/automerge/internal/reference" +) + +const ( + officialChangeFixture = "hW9Kg5nDjoUBzgEAEAECAwQFBgcICQoLDA0ODxABAYDiz6oGF29mZmljaWFsIHNjYWxhciBmaXh0dXJlAAoBBAIHEQYTBxU4NAJCCFYQVxtwAgALBAAACwILfgwNAAx/AAACAAt8AAx0AHUDbmlsAm5vA3llcwR1aW50A2ludAVmbG9hdAR0ZXh0BWJ5dGVzBHdoZW4FY291bnQEbGlzdAAECwQKAX8CAgQCAXYAAQITFIUBVjdpGAMAAhYqeQAAAAAAAPg/aGVsbG8A/wf70JX/vDEJYWIPAA==" + officialDocumentFixture = "hW9Kg3tNcOoAogIBEAECAwQFBgcICQoLDA0ODxABmcOOhfOq6K9fyRtQMpEkw5nRGiPrg0/hSLI3KA5LqKcHAQIDAhMCIwY1GUACVgIMAQQCBxEGEwcVOCECIw80AkIKVhJXG4ABAn8AfwF/D3+A4s+qBn8Xb2ZmaWNpYWwgc2NhbGFyIGZpeHR1cmV/AH8HAAsEAAALAgt+DA0ADH8AAAIAC3wADHQAdQVieXRlcwVjb3VudAVmbG9hdANpbnQEbGlzdANuaWwCbm8EdGV4dAR1aW50BHdoZW4DeWVzAAQPAHQIAnx/BnYBBX0FegkDAQsEBAF/AgYBAgQCAXw3GIUBFAIAewFWE2kCAgACFgD/BwkAAAAAAAD4P3loZWxsbyr70JX/vDFhYg8AAA==" +) + +func fixture(t *testing.T, encoded string) []byte { + t.Helper() + + data, err := base64.StdEncoding.DecodeString(encoded) + require.NoError(t, err) + + return data +} + +func scalarOperations(document *Document) map[string]Scalar { + result := make(map[string]Scalar) + + for _, operation := range document.Changes[0].Operations { + if operation.Action == ActionSet && operation.Key.Property != nil && operation.Value != nil { + result[*operation.Key.Property] = *operation.Value + } + } + + return result +} + +func TestDecode_OfficialDocumentFixture(t *testing.T) { + t.Parallel() + + document, err := Decode(fixture(t, officialDocumentFixture)) + require.NoError(t, err) + require.Len(t, document.Changes, 1) + require.Len(t, document.Heads, 1) + + change := document.Changes[0] + assert.Equal(t, uint64(1), change.Sequence) + assert.Equal(t, "official scalar fixture", change.Message) + assert.Equal(t, document.Heads[0], *change.Hash) + assert.Equal(t, "99c38e85f3aae8af5fc91b50329124c399d11a23eb834fe148b237280e4ba8a7", change.Hash.String()) + + values := scalarOperations(document) + require.Len(t, values, 10) + assert.Equal(t, ScalarNull, values["nil"].Type) + assert.Equal(t, ScalarFalse, values["no"].Type) + assert.Equal(t, ScalarTrue, values["yes"].Type) + assert.Equal(t, uint64(42), values["uint"].Uint) + assert.Equal(t, int64(-7), values["int"].Int) + assert.Equal(t, 1.5, values["float"].Float) + assert.Equal(t, "hello", values["text"].String) + assert.Equal(t, []byte{0, 255, 7}, values["bytes"].Bytes) + assert.Equal(t, int64(1_700_000_000_123), values["when"].Int) + assert.Equal(t, int64(9), values["count"].Int) +} + +func TestDecode_OfficialChangeFixture(t *testing.T) { + t.Parallel() + + document, err := Decode(fixture(t, officialChangeFixture)) + require.NoError(t, err) + require.Len(t, document.Changes, 1) + require.Len(t, document.Heads, 1) + + change := document.Changes[0] + assert.Equal(t, uint64(1), change.Sequence) + assert.Equal(t, uint64(1), change.StartOp) + assert.Equal(t, uint64(15), change.MaxOp) + assert.Equal(t, document.Heads[0], *change.Hash) + assert.Equal(t, "99c38e85f3aae8af5fc91b50329124c399d11a23eb834fe148b237280e4ba8a7", change.Hash.String()) +} + +func TestDecode_OfficialEmptyDocumentFixture(t *testing.T) { + t.Parallel() + + data := []byte{ + 0x85, 0x6f, 0x4a, 0x83, + 0xb8, 0x1a, 0x95, 0x44, + 0x00, 0x04, + 0x00, 0x00, 0x00, 0x00, + } + document, err := Decode(data) + require.NoError(t, err) + assert.Empty(t, document.Actors) + assert.Empty(t, document.Heads) + assert.Empty(t, document.Changes) +} + +func TestDecode_CompressedOfficialChangeFixture(t *testing.T) { + t.Parallel() + + uncompressed := fixture(t, officialChangeFixture) + r := &reader{data: uncompressed, offset: 9} + length, err := r.uleb() + require.NoError(t, err) + content, err := r.bytes(length) + require.NoError(t, err) + + var compressed bytes.Buffer + + writer, err := flate.NewWriter(&compressed, flate.BestCompression) + require.NoError(t, err) + _, err = writer.Write(content) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + data := append([]byte(nil), uncompressed[:8]...) + data = append(data, byte(ChunkCompressedChange)) + data = appendULEB(data, uint64(compressed.Len())) + data = append(data, compressed.Bytes()...) + + document, err := Decode(data) + require.NoError(t, err) + require.Len(t, document.Changes, 1) + assert.Equal(t, ChunkCompressedChange, document.ChunkTypes[0]) + 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 { + 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 { + 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", + } { + 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() + + ctx := context.Background() + backend, err := reference.New(ctx) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, backend.Close(ctx)) + }) + + actor := []byte{1, 3, 3, 7} + require.NoError(t, backend.SetActor(ctx, actor)) + require.NoError(t, backend.PutString(ctx, 0, "policy", "approved")) + _, err = backend.Commit(ctx, "reference fixture", time.Unix(1_700_000_000, 0)) + require.NoError(t, err) + data, err := backend.Save(ctx, true, true) + require.NoError(t, err) + + document, err := Decode(data) + require.NoError(t, err) + require.Len(t, document.Changes, 1) + assert.Equal(t, "01030307", document.Changes[0].Actor.String()) + assert.Equal(t, "reference fixture", document.Changes[0].Message) +} + +func TestDecode_ReferenceBackendConcurrentGraph(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := reference.New(ctx) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, base.Close(ctx)) + }) + require.NoError(t, base.SetActor(ctx, []byte{1})) + require.NoError(t, base.PutString(ctx, 0, "base", "value")) + _, err = base.Commit(ctx, "base", time.Unix(1, 0)) + require.NoError(t, err) + baseData, err := base.Save(ctx, true, true) + require.NoError(t, err) + + left, err := reference.Load(ctx, baseData) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, left.Close(ctx)) + }) + require.NoError(t, left.SetActor(ctx, []byte{2})) + require.NoError(t, left.PutString(ctx, 0, "left", "value")) + _, err = left.Commit(ctx, "left", time.Unix(2, 0)) + require.NoError(t, err) + + right, err := reference.Load(ctx, baseData) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, right.Close(ctx)) + }) + require.NoError(t, right.SetActor(ctx, []byte{3})) + require.NoError(t, right.PutString(ctx, 0, "right", "value")) + _, err = right.Commit(ctx, "right", time.Unix(3, 0)) + require.NoError(t, err) + rightData, err := right.Save(ctx, true, true) + require.NoError(t, err) + + _, err = left.Merge(ctx, rightData) + require.NoError(t, err) + mergedData, err := left.Save(ctx, true, true) + require.NoError(t, err) + + document, err := Decode(mergedData) + require.NoError(t, err) + require.Len(t, document.Changes, 3) + assert.Len(t, document.Heads, 2) + assert.Empty(t, document.Changes[0].DependencyIndexes) + assert.Equal(t, []uint64{0}, document.Changes[1].DependencyIndexes) + assert.Equal(t, []uint64{0}, document.Changes[2].DependencyIndexes) +} + +func TestDecode_RejectsCorruptChecksum(t *testing.T) { + t.Parallel() + + data := fixture(t, officialDocumentFixture) + data[len(data)-1] ^= 0xff + + _, err := Decode(data) + require.Error(t, err) + assert.ErrorContains(t, err, "checksum mismatch") +} + +func TestReaderULEB_RejectsNonCanonicalValue(t *testing.T) { + t.Parallel() + + r := &reader{data: []byte{0x80, 0x00}} + _, err := r.uleb() + require.Error(t, err) + assert.ErrorContains(t, err, "not minimally encoded") +} + +func TestValidateSnapshotGraph_RejectsCycle(t *testing.T) { + t.Parallel() + + actor, err := NewActorID([]byte{1}) + require.NoError(t, err) + + changes := []Change{ + { + Actor: actor, + Sequence: 1, + MaxOp: 1, + DependencyIndexes: []uint64{1}, + }, + { + Actor: actor, + Sequence: 2, + MaxOp: 2, + DependencyIndexes: []uint64{0}, + }, + } + + err = validateSnapshotGraph(changes, nil) + require.Error(t, err) + assert.ErrorContains(t, err, "dependency cycle") +} + +func TestValidateSnapshotGraph_RejectsSequenceGap(t *testing.T) { + t.Parallel() + + actor, err := NewActorID([]byte{1}) + require.NoError(t, err) + + changes := []Change{ + {Actor: actor, Sequence: 1, MaxOp: 1}, + {Actor: actor, Sequence: 3, MaxOp: 2, DependencyIndexes: []uint64{0}}, + } + + err = validateSnapshotGraph(changes, []uint64{1}) + require.Error(t, err) + assert.ErrorContains(t, err, "sequence 3, expected 2") +} diff --git a/pkg/automerge/internal/storage/encode.go b/pkg/automerge/internal/storage/encode.go new file mode 100644 index 0000000000..1e67e6750d --- /dev/null +++ b/pkg/automerge/internal/storage/encode.go @@ -0,0 +1,470 @@ +// 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 storage + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "math" + "sort" +) + +type encodedColumn struct { + specification uint32 + data []byte +} + +func EncodeChange(change *Change) ([]byte, error) { + if len(change.Actor) == 0 { + return nil, fmt.Errorf("change actor cannot be empty") + } + + if change.Sequence == 0 || change.StartOp == 0 { + return nil, fmt.Errorf("change sequence and start operation must be positive") + } + + actors, actorIndexes, err := changeActorTable(change) + if err != nil { + return nil, err + } + + columns, err := encodeOperationColumns(change, actorIndexes) + if err != nil { + return nil, err + } + + var body []byte + + body = appendHashesNative(body, change.Dependencies) + body = appendLengthPrefixedNative(body, change.Actor.Bytes()) + body = appendULEB(body, change.Sequence) + body = appendULEB(body, change.StartOp) + body = appendLEB(body, change.Time) + body = appendLengthPrefixedNative(body, []byte(change.Message)) + + body = appendULEB(body, uint64(len(actors))) + for _, actor := range actors { + body = appendLengthPrefixedNative(body, actor.Bytes()) + } + + body = appendColumns(body, columns) + body = append(body, change.ExtraBytes...) + + hashInput := []byte{byte(ChunkChange)} + hashInput = appendULEB(hashInput, uint64(len(body))) + hashInput = append(hashInput, body...) + hash := ChangeHash(sha256.Sum256(hashInput)) + change.Hash = new(hash) + + raw := []byte{0x85, 0x6f, 0x4a, 0x83} + raw = append(raw, hash[:4]...) + raw = append(raw, byte(ChunkChange)) + raw = appendULEB(raw, uint64(len(body))) + raw = append(raw, body...) + change.Raw = append([]byte(nil), raw...) + + return raw, nil +} + +func changeActorTable( + change *Change, +) ([]ActorID, map[ActorID]uint64, error) { + actorSet := make(map[ActorID]struct{}) + add := func(actor ActorID) { + if actor != "" && actor != change.Actor { + actorSet[actor] = struct{}{} + } + } + + for i, operation := range change.Operations { + expectedID := OpID{ + Actor: change.Actor, + Counter: change.StartOp + uint64(i), + } + if operation.ID != expectedID { + return nil, nil, fmt.Errorf( + "operation %d ID %v does not match implicit ID %v", + i, + operation.ID, + expectedID, + ) + } + + if !operation.Object.IsRoot { + add(operation.Object.OpID.Actor) + } + + if operation.Key.Element != nil { + add(operation.Key.Element.Actor) + } + + for _, predecessor := range operation.Predecessors { + add(predecessor.Actor) + } + } + + actors := make([]ActorID, 0, len(actorSet)) + for actor := range actorSet { + actors = append(actors, actor) + } + + sort.Slice(actors, func(i, j int) bool { + return actors[i].Compare(actors[j]) < 0 + }) + + indexes := map[ActorID]uint64{change.Actor: 0} + for i, actor := range actors { + indexes[actor] = uint64(i + 1) + } + + return actors, indexes, nil +} + +func encodeOperationColumns( + change *Change, + actorIndexes map[ActorID]uint64, +) ([]encodedColumn, error) { + count := len(change.Operations) + objActors := make([]optional[uint64], count) + objCounters := make([]optional[uint64], count) + keyActors := make([]optional[uint64], count) + keyCounters := make([]optional[int64], count) + keyStrings := make([]optional[string], count) + inserts := make([]bool, count) + 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] + hasMarkExpand bool + ) + + for i, operation := range change.Operations { + if !operation.Object.IsRoot { + actorIndex, ok := actorIndexes[operation.Object.OpID.Actor] + if !ok { + return nil, fmt.Errorf("operation %d object actor is unknown", i) + } + + objActors[i] = some(actorIndex) + objCounters[i] = some(operation.Object.OpID.Counter) + } + + switch { + case operation.Key.Property != nil: + keyStrings[i] = some(*operation.Key.Property) + case operation.Key.IsHead: + keyCounters[i] = some(int64(0)) + case operation.Key.Element != nil: + actorIndex, ok := actorIndexes[operation.Key.Element.Actor] + if !ok { + return nil, fmt.Errorf("operation %d key actor is unknown", i) + } + + keyActors[i] = some(actorIndex) + keyCounters[i] = some(int64(operation.Key.Element.Counter)) + default: + return nil, fmt.Errorf("operation %d has no key", i) + } + + inserts[i] = operation.Insert + actions[i] = some(uint64(operation.Action)) + + meta, data, err := encodeScalar(operation.Value) + if err != nil { + return nil, fmt.Errorf("cannot encode operation %d value: %w", i, err) + } + + valueMetadata[i] = meta + + valueData = append(valueData, data...) + + predGroups[i] = some(uint64(len(operation.Predecessors))) + for _, predecessor := range operation.Predecessors { + actorIndex, ok := actorIndexes[predecessor.Actor] + if !ok { + return nil, fmt.Errorf("operation %d predecessor actor is unknown", i) + } + + predActors = append(predActors, some(actorIndex)) + predCounters = append(predCounters, some(int64(predecessor.Counter))) + } + + // An all-false expand column carries no information and is left out, so a + // mark that expands in neither direction encodes without one. + if operation.MarkExpand != nil && *operation.MarkExpand { + markExpands[i] = true + hasMarkExpand = true + } + + if operation.MarkName != nil { + markNames[i] = some(*operation.MarkName) + } + } + + var markExpandData []byte + if hasMarkExpand { + markExpandData = encodeBooleans(markExpands) + } + + columns := []encodedColumn{ + {specification: 1, data: encodeRLE(objActors, appendULEB)}, + {specification: 2, data: encodeRLE(objCounters, appendULEB)}, + {specification: 17, data: encodeRLE(keyActors, appendULEB)}, + {specification: 19, data: encodeDelta(keyCounters)}, + {specification: 21, data: encodeStrings(keyStrings)}, + {specification: 52, data: encodeBooleans(inserts)}, + {specification: 66, data: encodeRLE(actions, appendULEB)}, + {specification: 86, data: encodeRLE(valueMetadata, appendULEB)}, + {specification: 87, data: valueData}, + {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] + for _, column := range columns { + if len(column.data) > 0 { + filtered = append(filtered, column) + } + } + + return filtered, nil +} + +func encodeScalar(value *Scalar) (optional[uint64], []byte, error) { + if value == nil { + return some(uint64(ScalarNull)), nil, nil + } + + var data []byte + + switch value.Type { + case ScalarNull: + case ScalarFalse, ScalarTrue: + case ScalarUint: + data = appendULEB(data, value.Uint) + case ScalarInt, ScalarCounter, ScalarTimestamp: + data = appendLEB(data, value.Int) + case ScalarFloat64: + var encoded [8]byte + binary.LittleEndian.PutUint64(encoded[:], math.Float64bits(value.Float)) + data = encoded[:] + case ScalarString: + data = []byte(value.String) + case ScalarBytes: + data = append([]byte(nil), value.Bytes...) + default: + data = append([]byte(nil), value.Raw...) + } + + meta := uint64(len(data))<<4 | uint64(value.Type) + + return some(meta), data, nil +} + +func appendColumns(data []byte, columns []encodedColumn) []byte { + sort.Slice(columns, func(i, j int) bool { + return columns[i].specification < columns[j].specification + }) + + data = appendULEB(data, uint64(len(columns))) + for _, column := range columns { + data = appendULEB(data, uint64(column.specification)) + data = appendULEB(data, uint64(len(column.data))) + } + + for _, column := range columns { + data = append(data, column.data...) + } + + return data +} + +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); { + if !values[index].valid { + end := index + 1 + for end < len(values) && !values[end].valid { + end++ + } + + data = appendLEB(data, 0) + data = appendULEB(data, uint64(end-index)) + index = end + + 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++ + } + + data = appendLEB(data, -int64(end-index)) + for _, value := range values[index:end] { + data = appendValue(data, value.value) + } + + index = end + } + + return data +} + +func encodeDelta(values []optional[int64]) []byte { + var ( + previous int64 + deltas = make([]optional[int64], len(values)) + ) + for i, value := range values { + if !value.valid { + continue + } + + deltas[i] = some(value.value - previous) + previous = value.value + } + + return encodeRLE(deltas, appendLEB) +} + +func encodeStrings(values []optional[string]) []byte { + return encodeRLE( + values, + func(data []byte, value string) []byte { + return appendLengthPrefixedNative(data, []byte(value)) + }, + ) +} + +func encodeBooleans(values []bool) []byte { + if len(values) == 0 { + return nil + } + + var ( + data []byte + current bool + count uint64 + ) + for _, value := range values { + if value == current { + count++ + continue + } + + data = appendULEB(data, count) + current = value + count = 1 + } + + return appendULEB(data, count) +} + +func appendLEB(data []byte, value int64) []byte { + for { + current := byte(value & 0x7f) + value >>= 7 + + done := (value == 0 && current&0x40 == 0) || + (value == -1 && current&0x40 != 0) + if !done { + current |= 0x80 + } + + data = append(data, current) + if done { + return data + } + } +} + +func appendHashesNative(data []byte, hashes []ChangeHash) []byte { + data = appendULEB(data, uint64(len(hashes))) + for _, hash := range hashes { + data = append(data, hash[:]...) + } + + return data +} + +func appendLengthPrefixedNative(data, value []byte) []byte { + data = appendULEB(data, uint64(len(value))) + return append(data, value...) +} + +func some[T any](value T) optional[T] { + return optional[T]{value: value, valid: true} +} + +func allNull[T any](values []optional[T]) bool { + for _, value := range values { + if value.valid { + return false + } + } + + return true +} diff --git a/pkg/automerge/internal/storage/encode_document.go b/pkg/automerge/internal/storage/encode_document.go new file mode 100644 index 0000000000..72ef49a151 --- /dev/null +++ b/pkg/automerge/internal/storage/encode_document.go @@ -0,0 +1,669 @@ +// 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 storage + +import ( + "bytes" + "crypto/sha256" + "fmt" + "slices" +) + +// assembleChunk frames a chunk body: the magic bytes, the first four bytes of +// the digest covering the typed and length-prefixed body, then that body. +func assembleChunk(kind ChunkType, body []byte) []byte { + length := appendULEB(nil, uint64(len(body))) + + digestInput := make([]byte, 0, 1+len(length)+len(body)) + digestInput = append(digestInput, byte(kind)) + digestInput = append(digestInput, length...) + digestInput = append(digestInput, body...) + + digest := sha256.Sum256(digestInput) + + chunk := make([]byte, 0, 4+4+1+len(length)+len(body)) + chunk = append(chunk, magic[:]...) + chunk = append(chunk, digest[:4]...) + chunk = append(chunk, byte(kind)) + chunk = append(chunk, length...) + chunk = append(chunk, body...) + + return chunk +} + +// EncodeDocument serializes a whole history as one compacted document chunk, +// the form Rust and JavaScript write from save(). +// +// A document chunk is not a concatenation of changes. It stores the operation +// set once, in operation-set order rather than per change, and reduces each +// change to a row of metadata whose ancestry is expressed as indexes into that +// same table. Deletes disappear into the successor lists of the operations they +// removed, and every operation's predecessors are recovered from those lists on +// the way back in. +// +// order gives the operation-set sequence: object by object, and within an +// object the order a reader would see. The caller owns that sequence because it +// requires the sequence state only the engine maintains. Operations named by +// order must exist in the history, and deletes must be left out of it. +// +// compress DEFLATEs individual columns above a size threshold, which is what +// save() does and save_nocompress() does not. The compressed bytes are not +// byte-identical to the reference because the DEFLATE implementations differ, so +// byte identity only holds for histories small enough that no column crosses the +// threshold; compression is a size optimization, and every column round-trips +// because the decoder inflates any column whose specification carries the +// compressed bit. +func EncodeDocument(document *Document, order []OpID, compress bool) ([]byte, error) { + changes, err := documentChangeOrder(document) + if err != nil { + return nil, err + } + + operations, err := documentOperations(changes, order) + if err != nil { + return nil, err + } + + actors := documentActorTable(changes, operations) + actorIndexes := make(map[ActorID]uint64, len(actors)) + + for i, actor := range actors { + actorIndexes[actor] = uint64(i) + } + + heads, headIndexes, err := documentHeads(changes) + if err != nil { + return nil, err + } + + changeColumns, err := encodeDocumentChangeColumns(changes, actorIndexes) + if err != nil { + return nil, err + } + + operationColumns, err := encodeDocumentOperationColumns(operations, actorIndexes) + if err != nil { + return nil, err + } + + changeColumns = compressColumns(sortColumns( + append(changeColumns, retainedColumns(document, changeColumnSpecifications)...), + ), compress) + operationColumns = compressColumns(sortColumns( + append(operationColumns, retainedColumns(document, operationColumnSpecifications)...), + ), compress) + + var body []byte + + body = appendULEB(body, uint64(len(actors))) + for _, actor := range actors { + body = appendLengthPrefixedNative(body, actor.Bytes()) + } + + body = appendULEB(body, uint64(len(heads))) + for _, head := range heads { + body = append(body, head[:]...) + } + + // Both column sets are described before either is written, so the metadata + // has to be laid out ahead of the data it measures. + body = appendColumnMetadata(body, changeColumns) + body = appendColumnMetadata(body, operationColumns) + body = appendColumnData(body, changeColumns) + body = appendColumnData(body, operationColumns) + + for _, index := range headIndexes { + body = appendULEB(body, index) + } + + return assembleChunk(ChunkDocument, body), nil +} + +// documentChangeOrder returns the changes in dependency order. A snapshot may +// legally store them in any order, but writing ancestors first keeps the index +// references pointing backwards, which is what every other implementation emits +// and what makes the result readable in one pass. +func documentChangeOrder(document *Document) ([]*Change, error) { + byHash := make(map[ChangeHash]*Change, len(document.Changes)) + + for i := range document.Changes { + change := &document.Changes[i] + if change.Hash == nil { + return nil, fmt.Errorf("change %d cannot be written without a hash", i) + } + + byHash[*change.Hash] = change + } + + ordered := make([]*Change, 0, len(document.Changes)) + placed := make(map[ChangeHash]struct{}, len(document.Changes)) + + var place func(*Change) error + + place = func(change *Change) error { + if _, ok := placed[*change.Hash]; ok { + return nil + } + + // Claim the change before descending so a cycle is reported rather than + // followed forever. + placed[*change.Hash] = struct{}{} + + for _, dependency := range change.Dependencies { + parent, ok := byHash[dependency] + if !ok { + return fmt.Errorf( + "change %s depends on %s which the history does not hold", + change.Hash, + dependency, + ) + } + + if err := place(parent); err != nil { + return err + } + } + + ordered = append(ordered, change) + + return nil + } + + for i := range document.Changes { + if err := place(&document.Changes[i]); err != nil { + return nil, err + } + } + + return ordered, nil +} + +// documentOperations collects the operations to store, in the given order, with +// each one's successors derived from the predecessors recorded across the whole +// history. Deletes are dropped: they exist in the result only as the successor +// entries they contribute. +func documentOperations(changes []*Change, order []OpID) ([]Operation, error) { + sources := make(map[OpID]*Operation) + + for _, change := range changes { + for i := range change.Operations { + operation := &change.Operations[i] + if _, ok := sources[operation.ID]; ok { + return nil, fmt.Errorf("operation %s@%d occurs twice", + operation.ID.Actor, operation.ID.Counter) + } + + sources[operation.ID] = operation + } + } + + successors := make(map[OpID][]OpID) + + for _, change := range changes { + for _, operation := range change.Operations { + for _, predecessor := range operation.Predecessors { + successors[predecessor] = append(successors[predecessor], operation.ID) + } + } + } + + for identifier := range successors { + slices.SortFunc(successors[identifier], func(left, right OpID) int { + return left.Compare(right) + }) + } + + operations := make([]Operation, 0, len(order)) + + for _, identifier := range order { + source, ok := sources[identifier] + if !ok { + return nil, fmt.Errorf( + "operation %s@%d is ordered but absent from the history", + identifier.Actor, + identifier.Counter, + ) + } + + if source.Action == ActionDelete { + return nil, fmt.Errorf( + "operation %s@%d is a delete and cannot be stored", + identifier.Actor, + identifier.Counter, + ) + } + + stored := *source + stored.Predecessors = nil + stored.Successors = successors[identifier] + + operations = append(operations, stored) + } + + return operations, nil +} + +func documentActorTable(changes []*Change, operations []Operation) []ActorID { + seen := make(map[ActorID]struct{}) + + add := func(actor ActorID) { + if actor != "" { + seen[actor] = struct{}{} + } + } + + for _, change := range changes { + add(change.Actor) + } + + for _, operation := range operations { + add(operation.ID.Actor) + + if !operation.Object.IsRoot { + add(operation.Object.OpID.Actor) + } + + if operation.Key.Element != nil { + add(operation.Key.Element.Actor) + } + + for _, successor := range operation.Successors { + add(successor.Actor) + } + } + + actors := make([]ActorID, 0, len(seen)) + for actor := range seen { + actors = append(actors, actor) + } + + slices.SortFunc(actors, func(left, right ActorID) int { + return left.Compare(right) + }) + + return actors +} + +// documentHeads returns the frontier and the index of each head, which is how a +// snapshot names its heads. +func documentHeads(changes []*Change) ([]ChangeHash, []uint64, error) { + indexes := make(map[ChangeHash]uint64, len(changes)) + dependedOn := make(map[ChangeHash]struct{}, len(changes)) + + for i, change := range changes { + indexes[*change.Hash] = uint64(i) + + for _, dependency := range change.Dependencies { + dependedOn[dependency] = struct{}{} + } + } + + heads := make([]ChangeHash, 0) + + for _, change := range changes { + if _, ok := dependedOn[*change.Hash]; !ok { + heads = append(heads, *change.Hash) + } + } + + slices.SortFunc(heads, func(left, right ChangeHash) int { + return bytes.Compare(left[:], right[:]) + }) + + headIndexes := make([]uint64, len(heads)) + for i, head := range heads { + headIndexes[i] = indexes[head] + } + + return heads, headIndexes, nil +} + +func encodeDocumentChangeColumns( + changes []*Change, + actorIndexes map[ActorID]uint64, +) ([]encodedColumn, error) { + count := len(changes) + + indexes := make(map[ChangeHash]uint64, count) + for i, change := range changes { + indexes[*change.Hash] = uint64(i) + } + + var ( + actors = make([]optional[uint64], count) + sequences = make([]optional[int64], count) + maxOps = make([]optional[int64], count) + times = make([]optional[int64], count) + messages = make([]optional[string], count) + dependencySize = make([]optional[uint64], count) + dependencies []optional[int64] + extraMetadata = make([]optional[uint64], count) + extraData []byte + ) + + for i, change := range changes { + actorIndex, ok := actorIndexes[change.Actor] + if !ok { + return nil, fmt.Errorf("change %d actor is not in the actor table", i) + } + + actors[i] = some(actorIndex) + sequences[i] = some(int64(change.Sequence)) + maxOps[i] = some(int64(change.MaxOp)) + times[i] = some(change.Time) + + if change.Message != "" { + messages[i] = some(change.Message) + } + + dependencySize[i] = some(uint64(len(change.Dependencies))) + + for _, dependency := range change.Dependencies { + index, ok := indexes[dependency] + if !ok { + return nil, fmt.Errorf("change %d depends on an absent change", i) + } + + dependencies = append(dependencies, some(int64(index))) + } + + metadata, data, err := encodeScalar(changeExtra(change)) + if err != nil { + return nil, fmt.Errorf("cannot encode change %d extra: %w", i, err) + } + + extraMetadata[i] = metadata + extraData = append(extraData, data...) + } + + columns := []encodedColumn{ + {specification: 1, data: encodeRLE(actors, appendULEB)}, + {specification: 3, data: encodeDelta(sequences)}, + {specification: 19, data: encodeDelta(maxOps)}, + {specification: 35, data: encodeDelta(times)}, + {specification: 53, data: encodeStrings(messages)}, + {specification: 64, data: encodeRLE(dependencySize, appendULEB)}, + {specification: 67, data: encodeDelta(dependencies)}, + {specification: 86, data: encodeRLE(extraMetadata, appendULEB)}, + {specification: 87, data: extraData}, + } + + return withData(columns), nil +} + +// changeExtra reports the change's extra payload as the scalar a snapshot +// stores. A change chunk keeps the payload as trailing bytes, so the two forms +// have to be reconciled in whichever direction carries the value. +func changeExtra(change *Change) *Scalar { + if len(change.ExtraBytes) > 0 { + return &Scalar{Type: ScalarBytes, Bytes: change.ExtraBytes} + } + + if change.Extra != nil { + return change.Extra + } + + // The payload is a byte string even when a change carries none, so an absent + // one is empty rather than null. + return &Scalar{Type: ScalarBytes} +} + +func encodeDocumentOperationColumns( + operations []Operation, + actorIndexes map[ActorID]uint64, +) ([]encodedColumn, error) { + count := len(operations) + + var ( + idActors = make([]optional[uint64], count) + idCounters = make([]optional[int64], count) + objectActors = make([]optional[uint64], count) + objectCounters = make([]optional[uint64], count) + keyActors = make([]optional[uint64], count) + keyCounters = make([]optional[int64], count) + keyStrings = make([]optional[string], count) + inserts = make([]bool, count) + actions = make([]optional[uint64], count) + valueMetadata = make([]optional[uint64], count) + valueData []byte + successorSize = make([]optional[uint64], count) + successorActors []optional[uint64] + successorCounter []optional[int64] + markExpands = make([]bool, count) + hasMarkExpand bool + markNames = make([]optional[string], count) + ) + + for i, operation := range operations { + index, ok := actorIndexes[operation.ID.Actor] + if !ok { + return nil, fmt.Errorf("operation %d actor is not in the actor table", i) + } + + idActors[i] = some(index) + idCounters[i] = some(int64(operation.ID.Counter)) + + if !operation.Object.IsRoot { + index, ok := actorIndexes[operation.Object.OpID.Actor] + if !ok { + return nil, fmt.Errorf("operation %d object actor is unknown", i) + } + + objectActors[i] = some(index) + objectCounters[i] = some(operation.Object.OpID.Counter) + } + + switch { + case operation.Key.Property != nil: + keyStrings[i] = some(*operation.Key.Property) + case operation.Key.IsHead: + keyCounters[i] = some(int64(0)) + case operation.Key.Element != nil: + index, ok := actorIndexes[operation.Key.Element.Actor] + if !ok { + return nil, fmt.Errorf("operation %d key actor is unknown", i) + } + + keyActors[i] = some(index) + keyCounters[i] = some(int64(operation.Key.Element.Counter)) + default: + return nil, fmt.Errorf("operation %d has no key", i) + } + + inserts[i] = operation.Insert + actions[i] = some(uint64(operation.Action)) + + metadata, data, err := encodeScalar(operation.Value) + if err != nil { + return nil, fmt.Errorf("cannot encode operation %d value: %w", i, err) + } + + valueMetadata[i] = metadata + valueData = append(valueData, data...) + + successorSize[i] = some(uint64(len(operation.Successors))) + + for _, successor := range operation.Successors { + index, ok := actorIndexes[successor.Actor] + if !ok { + return nil, fmt.Errorf("operation %d successor actor is unknown", i) + } + + successorActors = append(successorActors, some(index)) + successorCounter = append(successorCounter, some(int64(successor.Counter))) + } + + // Expand only means anything on a mark, and an all-false column is left + // out, so the flag is written only where it is actually set. + if operation.MarkExpand != nil && *operation.MarkExpand { + markExpands[i] = true + hasMarkExpand = true + } + + if operation.MarkName != nil { + markNames[i] = some(*operation.MarkName) + } + } + + var markExpandData []byte + if hasMarkExpand { + markExpandData = encodeBooleans(markExpands) + } + + columns := []encodedColumn{ + {specification: 1, data: encodeRLE(objectActors, appendULEB)}, + {specification: 2, data: encodeRLE(objectCounters, appendULEB)}, + {specification: 17, data: encodeRLE(keyActors, appendULEB)}, + {specification: 19, data: encodeDelta(keyCounters)}, + {specification: 21, data: encodeStrings(keyStrings)}, + {specification: 33, data: encodeRLE(idActors, appendULEB)}, + {specification: 35, data: encodeDelta(idCounters)}, + {specification: 52, data: encodeBooleans(inserts)}, + {specification: 66, data: encodeRLE(actions, appendULEB)}, + {specification: 86, data: encodeRLE(valueMetadata, appendULEB)}, + {specification: 87, data: valueData}, + {specification: 128, data: encodeRLE(successorSize, appendULEB)}, + {specification: 129, data: encodeRLE(successorActors, appendULEB)}, + {specification: 131, data: encodeDelta(successorCounter)}, + {specification: 148, data: markExpandData}, + {specification: 165, data: encodeStrings(markNames)}, + } + + return withData(columns), nil +} + +var ( + changeColumnSpecifications = []uint32{1, 3, 19, 35, 53, 64, 67, 86, 87} + operationColumnSpecifications = []uint32{ + 1, 2, 17, 19, 21, 33, 35, 52, 66, 86, 87, 128, 129, 131, 148, 165, + } +) + +// retainedColumns returns the columns a previous reader did not understand but +// kept, so writing a history back does not quietly drop what a newer version of +// the format put there. Each retained column is matched to the table it came +// from by its specification. +func retainedColumns(document *Document, known []uint32) []encodedColumn { + retained := make([]encodedColumn, 0) + + for _, column := range document.UnknownColumns { + normalized := column.Specification &^ 8 + if slices.Contains(known, normalized) || len(column.Data) == 0 { + continue + } + + retained = append(retained, encodedColumn{ + specification: normalized, + data: append([]byte(nil), column.Data...), + }) + } + + return retained +} + +// sortColumns puts columns in the strictly ascending order a reader requires, +// which both the metadata and the data must follow. Ordering is by the +// normalized specification so a column keeps its place whether or not it carries +// the compressed bit. +func sortColumns(columns []encodedColumn) []encodedColumn { + slices.SortFunc(columns, func(left, right encodedColumn) int { + leftSpec := left.specification &^ compressedColumnBit + rightSpec := right.specification &^ compressedColumnBit + + switch { + case leftSpec < rightSpec: + return -1 + case leftSpec > rightSpec: + return 1 + default: + return 0 + } + }) + + return columns +} + +// compressedColumnBit marks a column whose data is DEFLATE-compressed. A reader +// strips it to recover the logical specification and inflates the data. +const compressedColumnBit = 8 + +// columnDeflateMinSize is the smallest column worth compressing, matching the +// change-chunk threshold. Below it, compression tends to grow the data. +const columnDeflateMinSize = 250 + +// compressColumns DEFLATEs each column whose data is large enough to benefit, +// marking it with the compressed bit. A column that is already compressed (a +// retained unknown column) or that does not shrink is left untouched, so the +// result is never larger than the input. +func compressColumns(columns []encodedColumn, compress bool) []encodedColumn { + if !compress { + return columns + } + + for i := range columns { + column := &columns[i] + + if column.specification&compressedColumnBit != 0 || + len(column.data) < columnDeflateMinSize { + continue + } + + deflated, err := deflate(column.data) + if err != nil || len(deflated) >= len(column.data) { + continue + } + + column.specification |= compressedColumnBit + column.data = deflated + } + + return columns +} + +func withData(columns []encodedColumn) []encodedColumn { + filtered := make([]encodedColumn, 0, len(columns)) + + for _, column := range columns { + if len(column.data) > 0 { + filtered = append(filtered, column) + } + } + + return filtered +} + +func appendColumnMetadata(data []byte, columns []encodedColumn) []byte { + data = appendULEB(data, uint64(len(columns))) + for _, column := range columns { + data = appendULEB(data, uint64(column.specification)) + data = appendULEB(data, uint64(len(column.data))) + } + + return data +} + +func appendColumnData(data []byte, columns []encodedColumn) []byte { + for _, column := range columns { + data = append(data, column.data...) + } + + return data +} diff --git a/pkg/automerge/internal/storage/encode_document_reference_test.go b/pkg/automerge/internal/storage/encode_document_reference_test.go new file mode 100644 index 0000000000..f38fd9a679 --- /dev/null +++ b/pkg/automerge/internal/storage/encode_document_reference_test.go @@ -0,0 +1,246 @@ +// 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 storage + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge/internal/reference" +) + +func boolScalar() []byte { return []byte(`{"type":"boolean","bool":true}`) } +func nullScalar() []byte { return []byte(`{"type":"null"}`) } +func counterScalar() []byte { return []byte(`{"type":"counter","int":5}`) } + +func stringScalar(value string) []byte { + return []byte(`{"type":"string","string":"` + value + `"}`) +} + +func newReference(t *testing.T, ctx context.Context, actor byte) *reference.Engine { + t.Helper() + + engine, err := reference.New(ctx) + require.NoError(t, err) + require.NoError(t, engine.SetActor(ctx, []byte{ + actor, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + })) + + return engine +} + +// assertSnapshotReencodes decodes a reference-written snapshot and requires the +// encoder to reproduce it byte for byte. +func assertSnapshotReencodes(t *testing.T, saved []byte) { + t.Helper() + + document, err := Decode(saved) + require.NoError(t, err) + + encoded, err := EncodeDocument(document, storedOperationOrder(t, saved), true) + require.NoError(t, err) + + assert.Equal(t, saved, encoded) +} + +// TestEncodeDocument_MatchesReferenceSnapshots pins snapshot writing against the +// reference implementation across the shapes whose storage form differs most +// from a change: deletes that survive only as successors, marks whose expand +// column is shared, counters, nested objects and several actors. +func TestEncodeDocument_MatchesReferenceSnapshots(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, testCase := range []struct { + name string + build func(t *testing.T, engine *reference.Engine) []byte + }{ + { + name: "linear text history", + build: func(t *testing.T, engine *reference.Engine) []byte { + handle, err := engine.PutText(ctx, 0, "body") + require.NoError(t, err) + + for i := range 5 { + require.NoError(t, engine.SpliceText(ctx, handle, uint32(i), 0, "x")) + _, err = engine.Commit(ctx, "edit", time.Unix(int64(i+1), 0)) + require.NoError(t, err) + } + + saved, err := engine.Save(ctx, true, true) + require.NoError(t, err) + + return saved + }, + }, + { + name: "marks and unmarks", + build: func(t *testing.T, engine *reference.Engine) []byte { + handle, err := engine.PutText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, engine.SpliceText(ctx, handle, 0, 0, "hello brave world")) + _, err = engine.Commit(ctx, "write", time.Unix(1, 0)) + require.NoError(t, err) + + require.NoError(t, engine.MarkText(ctx, handle, 0, 5, "strong", boolScalar(), "both")) + _, err = engine.Commit(ctx, "mark", time.Unix(2, 0)) + require.NoError(t, err) + + require.NoError(t, engine.MarkText(ctx, handle, 1, 3, "strong", nullScalar(), "none")) + _, err = engine.Commit(ctx, "unmark", time.Unix(3, 0)) + require.NoError(t, err) + + saved, err := engine.Save(ctx, true, true) + require.NoError(t, err) + + return saved + }, + }, + { + name: "deletes and overwrites", + build: func(t *testing.T, engine *reference.Engine) []byte { + require.NoError(t, engine.PutString(ctx, 0, "title", "first")) + require.NoError(t, engine.PutString(ctx, 0, "keep", "value")) + _, err := engine.Commit(ctx, "one", time.Unix(1, 0)) + require.NoError(t, err) + + require.NoError(t, engine.PutString(ctx, 0, "title", "second")) + _, err = engine.Commit(ctx, "two", time.Unix(2, 0)) + require.NoError(t, err) + + require.NoError(t, engine.DeleteMap(ctx, 0, "title")) + _, err = engine.Commit(ctx, "three", time.Unix(3, 0)) + require.NoError(t, err) + + saved, err := engine.Save(ctx, true, true) + require.NoError(t, err) + + return saved + }, + }, + { + name: "list with deletion and counter", + build: func(t *testing.T, engine *reference.Engine) []byte { + list, err := engine.PutObject(ctx, 0, "items", "list") + require.NoError(t, err) + require.NoError(t, engine.InsertScalar(ctx, list, 0, stringScalar("a"))) + require.NoError(t, engine.InsertScalar(ctx, list, 1, stringScalar("b"))) + require.NoError(t, engine.InsertScalar(ctx, list, 2, stringScalar("c"))) + require.NoError(t, engine.PutScalar(ctx, 0, "counter", counterScalar())) + _, err = engine.Commit(ctx, "build", time.Unix(1, 0)) + require.NoError(t, err) + + require.NoError(t, engine.DeleteSequence(ctx, list, 1)) + require.NoError(t, engine.Increment(ctx, 0, "counter", 3)) + _, err = engine.Commit(ctx, "trim", time.Unix(2, 0)) + require.NoError(t, err) + + saved, err := engine.Save(ctx, true, true) + require.NoError(t, err) + + return saved + }, + }, + { + name: "nested objects", + build: func(t *testing.T, engine *reference.Engine) []byte { + outer, err := engine.PutObject(ctx, 0, "outer", "map") + require.NoError(t, err) + + inner, err := engine.PutObject(ctx, outer, "inner", "list") + require.NoError(t, err) + require.NoError(t, engine.InsertScalar(ctx, inner, 0, stringScalar("deep"))) + + text, err := engine.PutText(ctx, outer, "note") + require.NoError(t, err) + require.NoError(t, engine.SpliceText(ctx, text, 0, 0, "nested")) + + _, err = engine.Commit(ctx, "nest", time.Unix(1, 0)) + require.NoError(t, err) + + saved, err := engine.Save(ctx, true, true) + require.NoError(t, err) + + return saved + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + assertSnapshotReencodes(t, testCase.build(t, newReference(t, ctx, 0x20))) + }) + } +} + +// TestEncodeDocument_MatchesReferenceConcurrentSnapshot covers a merged history, +// where several actors share the actor table and a change has more than one +// dependency. +func TestEncodeDocument_MatchesReferenceConcurrentSnapshot(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + first := newReference(t, ctx, 0x20) + + require.NoError(t, first.PutString(ctx, 0, "title", "one")) + + body, err := first.PutText(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, first.SpliceText(ctx, body, 0, 0, "hello")) + _, err = first.Commit(ctx, "first", time.Unix(1, 0)) + require.NoError(t, err) + + shared, err := first.Save(ctx, true, true) + require.NoError(t, err) + + second, err := reference.Load(ctx, shared) + require.NoError(t, err) + require.NoError(t, second.SetActor(ctx, []byte{ + 0x10, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + })) + + secondBody, _, err := second.GetObject(ctx, 0, "body") + require.NoError(t, err) + require.NoError(t, second.SpliceText(ctx, secondBody, 5, 0, " there")) + require.NoError(t, second.PutString(ctx, 0, "title", "two")) + _, err = second.Commit(ctx, "second", time.Unix(2, 0)) + require.NoError(t, err) + + secondSave, err := second.Save(ctx, true, true) + require.NoError(t, err) + + _, err = first.Merge(ctx, secondSave) + require.NoError(t, err) + + require.NoError(t, first.SpliceText(ctx, body, 0, 1, "")) + _, err = first.Commit(ctx, "third", time.Unix(3, 0)) + require.NoError(t, err) + + saved, err := first.Save(ctx, true, true) + require.NoError(t, err) + + assertSnapshotReencodes(t, saved) +} diff --git a/pkg/automerge/internal/storage/encode_document_test.go b/pkg/automerge/internal/storage/encode_document_test.go new file mode 100644 index 0000000000..97d08fecb7 --- /dev/null +++ b/pkg/automerge/internal/storage/encode_document_test.go @@ -0,0 +1,119 @@ +// 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 storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// storedOperationOrder reports the operation-set order a document chunk was +// written in, which is the order the encoder has to be given to reproduce it. +func storedOperationOrder(t *testing.T, data []byte) []OpID { + t.Helper() + + r := &reader{data: data} + + chunk, err := decodeChunk(r) + require.NoError(t, err) + require.Equal(t, ChunkDocument, chunk.kind) + + content := &reader{data: chunk.content} + + actors, err := decodeActorArray(content, true) + require.NoError(t, err) + + _, err = decodeHashArray(content, true) + require.NoError(t, err) + + changeMetadata, err := parseColumnMetadata(content, true) + require.NoError(t, err) + + operationMetadata, err := parseColumnMetadata(content, true) + require.NoError(t, err) + + _, err = readColumns(content, changeMetadata) + require.NoError(t, err) + + operationColumns, err := readColumns(content, operationMetadata) + require.NoError(t, err) + + operations, _, err := decodeOperations(operationColumns, actors, false, nil) + require.NoError(t, err) + + order := make([]OpID, len(operations)) + for i, operation := range operations { + order[i] = operation.ID + } + + return order +} + +// TestEncodeDocument_ReproducesOfficialSnapshotBytes is the byte-identity gate +// for snapshot writing. Re-encoding a snapshot the reference implementation +// wrote has to produce that same file, which pins the column layout, the actor +// and head tables, the extra payload and the chunk framing all at once. +func TestEncodeDocument_ReproducesOfficialSnapshotBytes(t *testing.T) { + t.Parallel() + + data := fixture(t, officialDocumentFixture) + + document, err := Decode(data) + require.NoError(t, err) + + encoded, err := EncodeDocument(document, storedOperationOrder(t, data), true) + require.NoError(t, err) + + assert.Equal(t, data, encoded, "re-encoded snapshot must match the original bytes") +} + +// TestEncodeDocument_RoundTripsThroughDecode checks the written snapshot reads +// back as the same history even where byte identity is not the subject. +func TestEncodeDocument_RoundTripsThroughDecode(t *testing.T) { + t.Parallel() + + data := fixture(t, officialDocumentFixture) + + document, err := Decode(data) + require.NoError(t, err) + + encoded, err := EncodeDocument(document, storedOperationOrder(t, data), true) + require.NoError(t, err) + + reloaded, err := Decode(encoded) + require.NoError(t, err) + + require.Len(t, reloaded.Changes, len(document.Changes)) + assert.Equal(t, document.Heads, reloaded.Heads) + assert.Equal(t, document.Actors, reloaded.Actors) + + for i := range document.Changes { + expected := &document.Changes[i] + actual := &reloaded.Changes[i] + + assert.Equal(t, expected.Hash, actual.Hash, "change %d hash", i) + assert.Equal(t, expected.Raw, actual.Raw, "change %d bytes", i) + assert.Equal(t, expected.Message, actual.Message, "change %d message", i) + assert.Equal(t, expected.Time, actual.Time, "change %d time", i) + } +} diff --git a/pkg/automerge/internal/storage/encode_test.go b/pkg/automerge/internal/storage/encode_test.go new file mode 100644 index 0000000000..607da9eb12 --- /dev/null +++ b/pkg/automerge/internal/storage/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 storage + +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/storage/fixtures_test.go b/pkg/automerge/internal/storage/fixtures_test.go new file mode 100644 index 0000000000..245893ea64 --- /dev/null +++ b/pkg/automerge/internal/storage/fixtures_test.go @@ -0,0 +1,40 @@ +// 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 storage + +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=", +} diff --git a/pkg/automerge/internal/storage/model.go b/pkg/automerge/internal/storage/model.go new file mode 100644 index 0000000000..8b69df154a --- /dev/null +++ b/pkg/automerge/internal/storage/model.go @@ -0,0 +1,72 @@ +// 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 storage implements Automerge chunk and column encoding, decoding, and +// graph validation independently from the native execution engine. +package storage + +import "go.probo.inc/probo/pkg/automerge/internal/opset" + +type ( + ActorID = opset.ActorID + ChangeHash = opset.ChangeHash + OpID = opset.OpID + ObjectID = opset.ObjectID + Key = opset.Key + Action = opset.Action + ScalarType = opset.ScalarType + Scalar = opset.Scalar + Operation = opset.Operation + Change = opset.Change + ChunkType = opset.ChunkType + RawColumn = opset.RawColumn + Document = opset.Document +) + +const ( + ActionMakeMap = opset.ActionMakeMap + ActionSet = opset.ActionSet + ActionMakeList = opset.ActionMakeList + ActionDelete = opset.ActionDelete + ActionMakeText = opset.ActionMakeText + ActionIncrement = opset.ActionIncrement + ActionMakeTable = opset.ActionMakeTable + ActionMark = opset.ActionMark + + ScalarNull = opset.ScalarNull + ScalarFalse = opset.ScalarFalse + ScalarTrue = opset.ScalarTrue + ScalarUint = opset.ScalarUint + ScalarInt = opset.ScalarInt + ScalarFloat64 = opset.ScalarFloat64 + ScalarString = opset.ScalarString + ScalarBytes = opset.ScalarBytes + ScalarCounter = opset.ScalarCounter + ScalarTimestamp = opset.ScalarTimestamp + + ChunkDocument = opset.ChunkDocument + ChunkChange = opset.ChunkChange + ChunkCompressedChange = opset.ChunkCompressedChange +) + +var ( + NewActorID = opset.NewActorID + RootObject = opset.RootObject +) diff --git a/pkg/automerge/internal/storage/validate.go b/pkg/automerge/internal/storage/validate.go new file mode 100644 index 0000000000..6fc85bfddf --- /dev/null +++ b/pkg/automerge/internal/storage/validate.go @@ -0,0 +1,841 @@ +// 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 storage + +import ( + "bytes" + "fmt" + "math" + "slices" +) + +func decodeActorArray(r *reader, sorted bool) ([]ActorID, error) { + count, err := r.uleb() + if err != nil { + return nil, err + } + + if count > maxDecodedItems { + return nil, fmt.Errorf("actor count %d exceeds limit", count) + } + + actors := make([]ActorID, 0, count) + for i := range count { + value, err := decodeLengthPrefixed(r) + if err != nil { + return nil, fmt.Errorf("cannot decode actor %d: %w", i, err) + } + + actor, err := NewActorID(value) + if err != nil { + return nil, fmt.Errorf("actor %d: %w", i, err) + } + + if sorted && len(actors) > 0 && actors[len(actors)-1].Compare(actor) >= 0 { + return nil, fmt.Errorf("actor IDs are not strictly sorted at index %d", i) + } + + actors = append(actors, actor) + } + + return actors, nil +} + +func decodeLengthPrefixed(r *reader) ([]byte, error) { + length, err := r.uleb() + if err != nil { + return nil, err + } + + return r.bytes(length) +} + +func decodeHashArray(r *reader, sorted bool) ([]ChangeHash, error) { + count, err := r.uleb() + if err != nil { + return nil, err + } + + if count > maxDecodedItems { + return nil, fmt.Errorf("hash count %d exceeds limit", count) + } + + hashes := make([]ChangeHash, 0, count) + for i := range count { + value, err := r.bytes(32) + if err != nil { + return nil, fmt.Errorf("cannot decode hash %d: %w", i, err) + } + + hash := copyHash(value) + if sorted && len(hashes) > 0 && bytes.Compare(hashes[len(hashes)-1][:], hash[:]) >= 0 { + return nil, fmt.Errorf("hashes are not strictly sorted at index %d", i) + } + + hashes = append(hashes, hash) + } + + return hashes, nil +} + +func decodeRequiredDelta( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[uint64], error) { + data, err := requireColumn(columns, specification) + if err != nil { + return nil, err + } + + values, err := decodeDeltaColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, false); err != nil { + return nil, err + } + + return values, nil +} + +func decodeOptionalDelta( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[uint64], error) { + data := optionalColumn(columns, specification) + if data == nil { + return make([]optional[uint64], expected), nil + } + + values, err := decodeDeltaColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, true); err != nil { + return nil, err + } + + return values, nil +} + +func decodeOptionalSignedDelta( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[int64], error) { + data := optionalColumn(columns, specification) + if data == nil { + return make([]optional[int64], expected), nil + } + + values, err := decodeSignedDeltaColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, true); err != nil { + return nil, err + } + + return values, nil +} + +func decodeRequiredULEB( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[uint64], error) { + data, err := requireColumn(columns, specification) + if err != nil { + return nil, err + } + + values, err := decodeULEBColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, false); err != nil { + return nil, err + } + + return values, nil +} + +func decodeOptionalULEB( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[uint64], error) { + data := optionalColumn(columns, specification) + if data == nil { + return make([]optional[uint64], expected), nil + } + + values, err := decodeULEBColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, true); err != nil { + return nil, err + } + + return values, nil +} + +func decodeOptionalStrings( + columns map[uint32]column, + specification uint32, + name string, + expected int, +) ([]optional[string], error) { + data := optionalColumn(columns, specification) + if data == nil { + return make([]optional[string], expected), nil + } + + values, err := decodeStringColumn(data) + if err != nil { + return nil, fmt.Errorf("cannot decode %s: %w", name, err) + } + + if err := requireItems(name, values, expected, true); err != nil { + return nil, err + } + + return values, nil +} + +func decodeOptionalBooleans( + columns map[uint32]column, + specification uint32, + expected int, +) ([]optional[bool], error) { + data := optionalColumn(columns, specification) + + values := make([]optional[bool], expected) + if data == nil { + return values, nil + } + + decoded, err := decodeBooleanColumn(data, expected) + if err != nil { + return nil, err + } + + for i, value := range decoded { + values[i] = optional[bool]{value: value, valid: true} + } + + return values, nil +} + +func decodeOptionalScalars( + columns map[uint32]column, + metaSpecification uint32, + rawSpecification uint32, + expected int, +) ([]optional[Scalar], error) { + meta := optionalColumn(columns, metaSpecification) + + raw := optionalColumn(columns, rawSpecification) + if meta == nil { + if raw != nil { + return nil, fmt.Errorf("raw value column is missing metadata") + } + + return make([]optional[Scalar], expected), nil + } + + return decodeScalars(meta, raw, expected) +} + +func sumGroups(groups []optional[uint64]) (int, error) { + var total uint64 + + for i, group := range groups { + if !group.valid { + return 0, fmt.Errorf("group %d is null", i) + } + + if total > math.MaxUint64-group.value { + return 0, fmt.Errorf("group sizes overflow uint64") + } + + total += group.value + if total > maxDecodedItems { + return 0, fmt.Errorf("grouped column exceeds %d items", maxDecodedItems) + } + } + + return int(total), nil +} + +func decodeGroupedOpIDs( + columns map[uint32]column, + actors []ActorID, + groupSpec uint32, + actorSpec uint32, + counterSpec uint32, + expected int, + name string, +) ([][]OpID, error) { + result := make([][]OpID, expected) + + groupData := optionalColumn(columns, groupSpec) + if groupData == nil { + return result, nil + } + + groups, err := decodeULEBColumn(groupData) + if err != nil { + return nil, fmt.Errorf("cannot decode %s groups: %w", name, err) + } + + if err := requireItems(name+" group", groups, expected, false); err != nil { + return nil, err + } + + count, err := sumGroups(groups) + if err != nil { + return nil, err + } + + if count == 0 { + return result, nil + } + + actorData, err := requireColumn(columns, actorSpec) + if err != nil { + return nil, err + } + + actorIndexes, err := decodeULEBColumn(actorData) + if err != nil { + return nil, fmt.Errorf("cannot decode %s actors: %w", name, err) + } + + if err := requireItems(name+" actor", actorIndexes, count, false); err != nil { + return nil, err + } + + counters, err := decodeRequiredDelta(columns, counterSpec, name+" counter", count) + if err != nil { + return nil, err + } + + offset := 0 + + for i, group := range groups { + result[i] = make([]OpID, int(group.value)) + for j := range result[i] { + id, err := opIDFromIndexes(actorIndexes[offset+j], counters[offset+j], actors) + if err != nil { + return nil, fmt.Errorf("%s %d: %w", name, offset+j, err) + } + + result[i][j] = id + } + + offset += int(group.value) + } + + return result, nil +} + +func opIDFromIndexes( + actorIndex optional[uint64], + counter optional[uint64], + actors []ActorID, +) (OpID, error) { + if !actorIndex.valid || !counter.valid { + return OpID{}, fmt.Errorf("actor or counter is null") + } + + if actorIndex.value >= uint64(len(actors)) { + return OpID{}, fmt.Errorf("actor index %d is out of bounds", actorIndex.value) + } + + if counter.value == 0 { + return OpID{}, fmt.Errorf("counter is zero") + } + + return OpID{Actor: actors[actorIndex.value], Counter: counter.value}, nil +} + +func objectIDFromIndexes( + actorIndex optional[uint64], + counter optional[uint64], + actors []ActorID, +) (ObjectID, error) { + if !actorIndex.valid && !counter.valid { + return RootObject(), nil + } + + id, err := opIDFromIndexes(actorIndex, counter, actors) + if err != nil { + return ObjectID{}, err + } + + return ObjectID{OpID: id}, nil +} + +func keyFromColumns( + actorIndex optional[uint64], + counter optional[uint64], + property optional[string], + actors []ActorID, + insert bool, +) (Key, error) { + if property.valid { + if actorIndex.valid || counter.valid { + return Key{}, fmt.Errorf("property key also has an element ID") + } + + value := property.value + + return Key{Property: &value}, nil + } + + if !actorIndex.valid && insert && (!counter.valid || counter.value == 0) { + return Key{IsHead: true}, nil + } + + id, err := opIDFromIndexes(actorIndex, counter, actors) + if err != nil { + return Key{}, err + } + + return Key{Element: &id}, nil +} + +func collectUnknown(columns map[uint32]column) []RawColumn { + specifications := make([]uint32, 0, len(columns)) + for specification := range columns { + specifications = append(specifications, specification) + } + + slices.Sort(specifications) + + result := make([]RawColumn, 0, len(columns)) + for _, specification := range specifications { + value := columns[specification] + result = append( + result, + RawColumn{ + Specification: value.specification, + Data: append([]byte(nil), value.data...), + }, + ) + } + + return result +} + +func assignOperations(changes []Change, operations []Operation) error { + byActor := make(map[ActorID][]int) + for i := range changes { + byActor[changes[i].Actor] = append(byActor[changes[i].Actor], i) + } + + for actor, indexes := range byActor { + slices.SortFunc( + indexes, + func(left, right int) int { + switch { + case changes[left].MaxOp < changes[right].MaxOp: + return -1 + case changes[left].MaxOp > changes[right].MaxOp: + return 1 + default: + return 0 + } + }, + ) + + var previous uint64 + for _, index := range indexes { + changes[index].StartOp = previous + 1 + previous = changes[index].MaxOp + } + + byActor[actor] = indexes + } + + // Each change holds exactly the operations whose counter falls in its + // [StartOp, MaxOp] range, so its operation slice can be sized once here. + // Growing it by append instead reallocated repeatedly and dominated the cost + // of loading a large single-change document. + for i := range changes { + size := changes[i].MaxOp - changes[i].StartOp + 1 + changes[i].Operations = make([]Operation, 0, size) + } + + for _, operation := range operations { + indexes := byActor[operation.ID.Actor] + + index, found := slices.BinarySearchFunc( + indexes, + operation.ID.Counter, + func(index int, counter uint64) int { + switch { + case changes[index].MaxOp < counter: + return -1 + case changes[index].MaxOp > counter: + return 1 + default: + return 0 + } + }, + ) + if !found { + if index >= len(indexes) { + return fmt.Errorf( + "operation %s@%d has no containing change", + operation.ID.Actor, + operation.ID.Counter, + ) + } + + // BinarySearchFunc returns the insertion point, which is the first + // change whose maxOp is greater than this counter. + } + + changeIndex := indexes[index] + if operation.ID.Counter < changes[changeIndex].StartOp { + return fmt.Errorf("operation counter precedes containing change start") + } + + changes[changeIndex].Operations = append(changes[changeIndex].Operations, operation) + } + + for i := range changes { + slices.SortFunc(changes[i].Operations, func(left, right Operation) int { + return left.ID.Compare(right.ID) + }) + } + + // The per-actor bounds above are a permissive lower bound that only has to + // locate operations. A change's operations carry consecutive counters ending + // at maxOp, so the real start operation follows from the operation count, and + // re-encoding the change depends on it being exact. + for i := range changes { + count := uint64(len(changes[i].Operations)) + if count > changes[i].MaxOp { + return fmt.Errorf( + "change %d holds %d operations but ends at operation %d", + i, + count, + changes[i].MaxOp, + ) + } + + changes[i].StartOp = changes[i].MaxOp - count + 1 + } + + return nil +} + +func validateSnapshotGraph(changes []Change, heads []uint64) error { + dependedOn := make([]bool, len(changes)) + for i, change := range changes { + seen := make(map[uint64]struct{}, len(change.DependencyIndexes)) + for _, dependency := range change.DependencyIndexes { + if dependency >= uint64(len(changes)) { + return fmt.Errorf("change %d dependency %d is out of bounds", i, dependency) + } + + if dependency == uint64(i) { + return fmt.Errorf("change %d depends on itself", i) + } + + if _, ok := seen[dependency]; ok { + return fmt.Errorf("change %d repeats dependency %d", i, dependency) + } + + seen[dependency] = struct{}{} + dependedOn[dependency] = true + } + } + + if err := detectIndexCycle(changes); err != nil { + return err + } + + expectedHeads := make(map[uint64]struct{}) + + for i, isDependedOn := range dependedOn { + if !isDependedOn { + expectedHeads[uint64(i)] = struct{}{} + } + } + + actualHeads := make(map[uint64]struct{}, len(heads)) + for _, head := range heads { + if _, exists := actualHeads[head]; exists { + return fmt.Errorf("head index %d is repeated", head) + } + + actualHeads[head] = struct{}{} + } + + if !mapsEqual(expectedHeads, actualHeads) { + return fmt.Errorf("head indexes do not match graph frontier") + } + + return validateActorSequences(changes) +} + +func detectIndexCycle(changes []Change) error { + state := make([]uint8, len(changes)) + + var visit func(int) error + + visit = func(index int) error { + switch state[index] { + case 1: + return fmt.Errorf("dependency cycle includes change %d", index) + case 2: + return nil + } + + state[index] = 1 + for _, dependency := range changes[index].DependencyIndexes { + if err := visit(int(dependency)); err != nil { + return err + } + } + + state[index] = 2 + + return nil + } + for i := range changes { + if err := visit(i); err != nil { + return err + } + } + + return nil +} + +func mapsEqual[K comparable](left, right map[K]struct{}) bool { + if len(left) != len(right) { + return false + } + + for key := range left { + if _, ok := right[key]; !ok { + return false + } + } + + return true +} + +func validateActorSequences(changes []Change) error { + byActor := make(map[ActorID][]Change) + for _, change := range changes { + byActor[change.Actor] = append(byActor[change.Actor], change) + } + + for actor, actorChanges := range byActor { + slices.SortFunc(actorChanges, func(left, right Change) int { + switch { + case left.Sequence < right.Sequence: + return -1 + case left.Sequence > right.Sequence: + return 1 + default: + return 0 + } + }) + + var previousMax uint64 + + for i, change := range actorChanges { + expectedSequence := uint64(i + 1) + if change.Sequence != expectedSequence { + return fmt.Errorf( + "actor %s has sequence %d, expected %d", + actor, + change.Sequence, + expectedSequence, + ) + } + + 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", + actor, + change.Sequence, + change.MaxOp, + ) + } + + previousMax = change.MaxOp + } + } + + return nil +} + +func mergeActors(existing, additions []ActorID) []ActorID { + set := make(map[ActorID]struct{}, len(existing)+len(additions)) + for _, actor := range existing { + set[actor] = struct{}{} + } + + for _, actor := range additions { + set[actor] = struct{}{} + } + + result := make([]ActorID, 0, len(set)) + for actor := range set { + result = append(result, actor) + } + + slices.SortFunc(result, func(left, right ActorID) int { + return left.Compare(right) + }) + + return result +} + +func validateDocument(document *Document) error { + if len(document.Changes) == 0 { + if len(document.Heads) != 0 { + return fmt.Errorf("empty history has heads") + } + + return nil + } + + if len(document.ChunkTypes) > 0 && document.ChunkTypes[0] == ChunkDocument { + return validateChangeChunksAfterSnapshot(document) + } + + return validateChangeChunkGraph(document) +} + +func validateChangeChunksAfterSnapshot(document *Document) error { + known := make(map[ChangeHash]struct{}) + dependedOn := make(map[ChangeHash]struct{}) + + // A change may legitimately appear both inside the snapshot and as a trailing + // change chunk, so repeats identify the same change rather than a conflict. + for _, change := range document.Changes { + if change.Hash != nil { + known[*change.Hash] = struct{}{} + } + } + + for _, change := range document.Changes { + for _, dependency := range change.Dependencies { + if _, ok := known[dependency]; !ok { + return fmt.Errorf("change %s has missing dependency %s", change.Hash, dependency) + } + + dependedOn[dependency] = struct{}{} + } + } + + document.Heads = document.Heads[:0] + + for hash := range known { + if _, ok := dependedOn[hash]; !ok { + document.Heads = append(document.Heads, hash) + } + } + + slices.SortFunc(document.Heads, func(left, right ChangeHash) int { + return bytes.Compare(left[:], right[:]) + }) + + return validateActorSequences(document.Changes) +} + +func validateChangeChunkGraph(document *Document) error { + changes := make(map[ChangeHash]Change, len(document.Changes)) + dependedOn := make(map[ChangeHash]struct{}) + + for _, change := range document.Changes { + if change.Hash == nil { + return fmt.Errorf("change chunk has no hash") + } + + if _, exists := changes[*change.Hash]; exists { + return fmt.Errorf("duplicate change %s", change.Hash) + } + + changes[*change.Hash] = change + } + + for _, change := range document.Changes { + for _, dependency := range change.Dependencies { + if _, ok := changes[dependency]; !ok { + return fmt.Errorf("change %s has missing dependency %s", change.Hash, dependency) + } + + dependedOn[dependency] = struct{}{} + } + } + + document.Heads = document.Heads[:0] + + for hash := range changes { + if _, ok := dependedOn[hash]; !ok { + document.Heads = append(document.Heads, hash) + } + } + + slices.SortFunc(document.Heads, func(left, right ChangeHash) int { + return bytes.Compare(left[:], right[:]) + }) + + return validateActorSequences(document.Changes) +} diff --git a/pkg/automerge/internal/sync/message.go b/pkg/automerge/internal/sync/message.go new file mode 100644 index 0000000000..59c20fdaa7 --- /dev/null +++ b/pkg/automerge/internal/sync/message.go @@ -0,0 +1,284 @@ +// 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 sync implements the Automerge V1/V2 sync message wire format. +package sync + +import "fmt" + +type ( + MessageVersion byte + + Have struct { + LastSync [][32]byte + Bloom []byte + } + + Message struct { + Version MessageVersion + Heads [][32]byte + Need [][32]byte + Have []Have + Changes [][]byte + Flags []byte + } +) + +const ( + MessageVersion1 MessageVersion = 0x42 + MessageVersion2 MessageVersion = 0x43 + + maxHaveEntries = 1024 + maxSyncChanges = 1024 * 1024 + maxSyncBloomBytes = 1024 * 1024 + maxSyncFlagsBytes = 1024 + maxSyncChunkBytes = 64 * 1024 * 1024 + maxSyncHashes = 1024 * 1024 +) + +func ParseMessage(data []byte) (*Message, error) { + r := &reader{data: data} + + versionByte, err := r.byte() + if err != nil { + return nil, fmt.Errorf("cannot read sync message version: %w", err) + } + + version := MessageVersion(versionByte) + if version != MessageVersion1 && version != MessageVersion2 { + return nil, fmt.Errorf("unsupported sync message version 0x%02x", version) + } + + heads, err := readSyncHashes(r) + if err != nil { + return nil, fmt.Errorf("cannot read sync heads: %w", err) + } + + need, err := readSyncHashes(r) + if err != nil { + return nil, fmt.Errorf("cannot read sync needs: %w", err) + } + + haveCount, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot read sync have count: %w", err) + } + + if haveCount > maxHaveEntries { + return nil, fmt.Errorf("sync have count %d exceeds limit", haveCount) + } + + have := make([]Have, int(haveCount)) + for i := range have { + have[i].LastSync, err = readSyncHashes(r) + if err != nil { + return nil, fmt.Errorf("cannot read sync have %d heads: %w", i, err) + } + + have[i].Bloom, err = readSyncBytes(r, maxSyncBloomBytes) + if err != nil { + return nil, fmt.Errorf("cannot read sync have %d bloom: %w", i, err) + } + } + + changeCount, err := r.uleb() + if err != nil { + return nil, fmt.Errorf("cannot read sync change count: %w", err) + } + + if changeCount > maxSyncChanges { + return nil, fmt.Errorf("sync change count %d exceeds limit", changeCount) + } + + changes := make([][]byte, int(changeCount)) + for i := range changes { + changes[i], err = readSyncBytes(r, maxSyncChunkBytes) + if err != nil { + return nil, fmt.Errorf("cannot read sync change %d: %w", i, err) + } + } + + var flags []byte + if r.remaining() > 0 { + flags, err = readSyncBytes(r, maxSyncFlagsBytes) + if err != nil { + return nil, fmt.Errorf("cannot read sync flags: %w", err) + } + } + + if r.remaining() > 0 { + return nil, fmt.Errorf("sync message contains trailing bytes") + } + + return &Message{ + Version: version, + Heads: heads, + Need: need, + Have: have, + Changes: changes, + Flags: flags, + }, nil +} + +func (m Message) Encode() ([]byte, error) { + if m.Version != MessageVersion1 && m.Version != MessageVersion2 { + return nil, fmt.Errorf("unsupported sync message version 0x%02x", m.Version) + } + + if len(m.Have) > maxHaveEntries || len(m.Changes) > maxSyncChanges { + return nil, fmt.Errorf("sync message exceeds collection limits") + } + + data := []byte{byte(m.Version)} + data = appendHashes(data, m.Heads) + data = appendHashes(data, m.Need) + + data = appendULEB(data, uint64(len(m.Have))) + for _, have := range m.Have { + data = appendHashes(data, have.LastSync) + data = appendLengthPrefixedBytes(data, have.Bloom) + } + + data = appendULEB(data, uint64(len(m.Changes))) + for _, change := range m.Changes { + data = appendLengthPrefixedBytes(data, change) + } + + if m.Flags != nil { + data = appendLengthPrefixedBytes(data, m.Flags) + } + + return data, nil +} + +func appendHashes(data []byte, hashes [][32]byte) []byte { + data = appendULEB(data, uint64(len(hashes))) + for _, hash := range hashes { + data = append(data, hash[:]...) + } + + return data +} + +func appendLengthPrefixedBytes(data, value []byte) []byte { + data = appendULEB(data, uint64(len(value))) + return append(data, value...) +} + +func readSyncHashes(r *reader) ([][32]byte, error) { + count, err := r.uleb() + if err != nil { + return nil, err + } + + if count > maxSyncHashes { + return nil, fmt.Errorf("sync hash count %d exceeds limit", count) + } + + hashes := make([][32]byte, int(count)) + for i := range hashes { + value, err := r.bytes(32) + if err != nil { + return nil, fmt.Errorf("cannot read sync hash %d: %w", i, err) + } + + copy(hashes[i][:], value) + } + + return hashes, nil +} + +func readSyncBytes(r *reader, limit uint64) ([]byte, error) { + length, err := r.uleb() + if err != nil { + return nil, err + } + + if length > limit { + return nil, fmt.Errorf("sync byte length %d exceeds limit %d", length, limit) + } + + value, err := r.bytes(length) + if err != nil { + return nil, err + } + + return append([]byte(nil), value...), nil +} + +type reader struct { + data []byte + offset int +} + +func (r *reader) remaining() int { return len(r.data) - r.offset } + +func (r *reader) byte() (byte, error) { + if r.remaining() < 1 { + return 0, fmt.Errorf("unexpected end of data") + } + + b := r.data[r.offset] + r.offset++ + + return b, nil +} + +func (r *reader) bytes(length uint64) ([]byte, error) { + if length > uint64(r.remaining()) { + return nil, fmt.Errorf("need %d bytes, only %d remain", length, r.remaining()) + } + + start := r.offset + r.offset += int(length) + + return r.data[start:r.offset], nil +} + +func (r *reader) uleb() (uint64, error) { + var value uint64 + + for shift := uint(0); shift < 64; shift += 7 { + b, err := r.byte() + if err != nil { + return 0, err + } + + if shift == 63 && b > 1 { + return 0, fmt.Errorf("ULEB128 overflow") + } + + value |= uint64(b&0x7f) << shift + if b&0x80 == 0 { + return value, nil + } + } + + return 0, fmt.Errorf("ULEB128 overflow") +} + +func appendULEB(data []byte, value uint64) []byte { + for value >= 0x80 { + data = append(data, byte(value)|0x80) + value >>= 7 + } + + return append(data, byte(value)) +} diff --git a/pkg/automerge/internal/sync/message_test.go b/pkg/automerge/internal/sync/message_test.go new file mode 100644 index 0000000000..e919365a8b --- /dev/null +++ b/pkg/automerge/internal/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 sync + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMessageEncodeDecodeEmptyV2 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 TestMessageEncodeDecodeEmptyV2(t *testing.T) { + t.Parallel() + + message := Message{Version: MessageVersion2} + + encoded, err := message.Encode() + require.NoError(t, err) + assert.Equal(t, []byte{byte(MessageVersion2), 0x00, 0x00, 0x00, 0x00}, encoded) + + parsed, err := ParseMessage(encoded) + require.NoError(t, err) + assert.Equal(t, MessageVersion2, 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/invariants_test.go b/pkg/automerge/invariants_test.go new file mode 100644 index 0000000000..27a0730443 --- /dev/null +++ b/pkg/automerge/invariants_test.go @@ -0,0 +1,634 @@ +// 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, []automerge.Change{changes[1]})) + 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, []automerge.Change{changes[0]})) + missing, err = target.MissingDependencies( + ctx, + []automerge.Hash{childHash}, + ) + require.NoError(t, err) + assert.Empty(t, missing) + require.NoError(t, target.ApplyChanges(ctx, []automerge.Change{ + changes[0], + changes[1], + })) + + 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, []automerge.Change{ + {Bytes: []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) + + // An unknown baseline head excludes nothing, matching Rust's get_changes, + // which takes have_deps by value and never errors: the whole history is + // returned rather than failing. This is what keeps collaboration persistence + // working when a frontier references a change that is no longer retrievable. + var unknown automerge.Hash + + unknown[0] = 1 + changes, err := document.ChangesSince(ctx, []automerge.Hash{unknown}) + require.NoError(t, err) + require.Len(t, changes, 1) +} + +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..90c055b073 --- /dev/null +++ b/pkg/automerge/js_block_parity_test.go @@ -0,0 +1,236 @@ +// 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..00133f1304 --- /dev/null +++ b/pkg/automerge/js_marks_parity_test.go @@ -0,0 +1,85 @@ +// 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..238399800c --- /dev/null +++ b/pkg/automerge/js_text_parity_test.go @@ -0,0 +1,215 @@ +// 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(ctx) + 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..72b2f5ba81 --- /dev/null +++ b/pkg/automerge/list_range_parity_test.go @@ -0,0 +1,205 @@ +// 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 := range 20 { + 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 := range branches { + fork, err := base.Fork(ctx, actor(byte(30+branch))) + require.NoError(t, err) + closeDocument(t, fork) + + for commit := range 2 { + 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 := range length { + 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 := range length { + 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_dangling_parity_test.go b/pkg/automerge/marks_dangling_parity_test.go new file mode 100644 index 0000000000..2a91a132ce --- /dev/null +++ b/pkg/automerge/marks_dangling_parity_test.go @@ -0,0 +1,351 @@ +// 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/rand" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// markScenarioStep is one editing step in a reproducible mark scenario. +type markScenarioStep struct { + kind string + index uint32 + end uint32 + count int32 + value string + name string + expand automerge.MarkExpand +} + +func (s markScenarioStep) String() string { + switch s.kind { + case "insert": + return fmt.Sprintf("insert(%d,%q)", s.index, s.value) + case "delete": + return fmt.Sprintf("delete(%d,%d)", s.index, s.count) + case "split": + return fmt.Sprintf("split(%d)", s.index) + default: + return fmt.Sprintf("mark(%d,%d,%s,%s)", s.index, s.end, s.name, s.expand) + } +} + +// TestRustText_DanglingMarkBoundaries gates the dangling-begin behavior: a mark +// applied with an out-of-range end boundary fails, but the reference has already +// recorded the begin. That dangling begin then captures text according to its +// expand direction, including text inserted to its left when the mark expands +// before. Native now starts a leftward-expanding dangling begin at the position +// just after its own anchor, matching the reference. +// +// The block-boundary and multi-dangling cases below were minimized by delta +// debugging from randomized differential runs. They all shared one cause: a +// split block did not resolve its insertion anchor against neighbouring mark +// boundaries the way a text insertion does, so a block, and every insertion +// anchored after it, landed on the wrong side of a dangling begin. SplitBlock +// now resolves its anchor identically to Splice, and a value-level randomized +// sweep including out-of-range marks and every expand mode no longer diverges. +func TestRustText_DanglingMarkBoundaries(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + tests := []struct { + name string + steps []markScenarioStep + }{ + { + name: "expand before captures a later left insertion", + steps: []markScenarioStep{ + {kind: "mark", index: 0, end: 3, name: "bold", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "w"}, + }, + }, + { + name: "expand both captures a later left insertion", + steps: []markScenarioStep{ + {kind: "mark", index: 0, end: 5, name: "underline", expand: automerge.MarkExpandBoth}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "i"}, + }, + }, + { + name: "dangling begin survives a full delete", + steps: []markScenarioStep{ + {kind: "mark", index: 0, end: 1, name: "italic", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 0}, + {kind: "mark", index: 0, end: 1, name: "italic", expand: automerge.MarkExpandBoth}, + {kind: "delete", index: 0, count: 5}, + {kind: "insert", index: 0, value: "G"}, + }, + }, + { + name: "dangling begin spans a block boundary", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 1, end: 3, name: "underline", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 1}, + {kind: "insert", index: 0, value: "Roa"}, + {kind: "insert", index: 4, value: "J"}, + }, + }, + { + name: "two dangling begins both survive", + steps: []markScenarioStep{ + {kind: "mark", index: 0, end: 3, name: "italic", expand: automerge.MarkExpandBoth}, + {kind: "mark", index: 0, end: 3, name: "bold", expand: automerge.MarkExpandBoth}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "u"}, + }, + }, + { + name: "overlapping begins with mixed expand both survive", + steps: []markScenarioStep{ + {kind: "mark", index: 0, end: 1, name: "italic", expand: automerge.MarkExpandBoth}, + {kind: "mark", index: 0, end: 1, name: "underline", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "vJ"}, + }, + }, + { + name: "dangling begin after a full delete and resplit", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 1, end: 4, name: "bold", expand: automerge.MarkExpandBoth}, + {kind: "delete", index: 0, count: 4}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "rG"}, + }, + }, + { + name: "none expand mark does not leak past a block", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 0, end: 1, name: "bold", expand: automerge.MarkExpandNone}, + {kind: "split", index: 1}, + {kind: "insert", index: 1, value: "Plk"}, + }, + }, + { + name: "before expand mark does not leak across a block", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 0, end: 1, name: "bold", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 1}, + {kind: "insert", index: 1, value: "lMf"}, + }, + }, + { + name: "before expand mark bounded by two blocks", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 0, end: 1, name: "bold", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 1}, + {kind: "split", index: 1}, + {kind: "insert", index: 2, value: "ed"}, + }, + }, + { + name: "before expand mark across a block at head", + steps: []markScenarioStep{ + {kind: "split", index: 0}, + {kind: "mark", index: 0, end: 1, name: "italic", expand: automerge.MarkExpandBefore}, + {kind: "split", index: 0}, + {kind: "insert", index: 0, value: "C"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equalf(t, + runMarkScenario(t, ctx, rustParityEngines()[1], tt.steps), + runMarkScenario(t, ctx, rustParityEngines()[0], tt.steps), + "steps: %s", renderMarkScenario(tt.steps), + ) + }) + } +} + +// TestRustText_MarkValuesMatchReferenceUnderErrors is the standing gate for the +// dangling-mark behavior: many randomized scenarios, deliberately including marks +// whose end boundary runs past the text and every expand mode, must produce the +// exact same marked spans on the native and reference engines. This is stronger +// than the consolidation-and-text invariants of marks_are_okay because it +// compares the mark values run for run, which is what caught the block-anchor +// divergence this gate now protects against. +func TestRustText_MarkValuesMatchReferenceUnderErrors(t *testing.T) { + t.Parallel() + + ctx := context.Background() + random := rand.New(rand.NewSource(0x1e3779b97f4a7c15)) + + const scenarios = 2000 + + for scenario := range scenarios { + steps := randomDanglingMarkSteps(random) + + reference := runMarkScenario(t, ctx, rustParityEngines()[1], steps) + native := runMarkScenario(t, ctx, rustParityEngines()[0], steps) + + require.Equalf(t, reference, native, + "scenario %d diverged; steps: %s", scenario, renderMarkScenario(steps)) + } +} + +// randomDanglingMarkSteps builds a random editing scenario over text, list and +// mark operations. Marks may target an end boundary past the current length so +// the error path that leaves a dangling begin is exercised, and every expand +// mode appears. +func randomDanglingMarkSteps(random *rand.Rand) []markScenarioStep { + names := []string{"bold", "italic", "underline"} + expands := []automerge.MarkExpand{ + automerge.MarkExpandNone, + automerge.MarkExpandBefore, + automerge.MarkExpandAfter, + automerge.MarkExpandBoth, + } + + steps := 3 + random.Intn(10) + out := make([]markScenarioStep, 0, steps) + length := 0 + + for range steps { + switch random.Intn(4) { + case 0: + index := random.Intn(length + 1) + value := randomLetters(random, 1+random.Intn(3)) + out = append(out, markScenarioStep{kind: "insert", index: uint32(index), value: value}) + length += len(value) + case 1: + if length == 0 { + continue + } + + index := random.Intn(length) + count := 1 + random.Intn(length-index) + out = append(out, markScenarioStep{kind: "delete", index: uint32(index), count: int32(count)}) + length -= count + case 2: + index := random.Intn(length + 1) + out = append(out, markScenarioStep{kind: "split", index: uint32(index)}) + length++ + case 3: + if length == 0 { + continue + } + + index := random.Intn(length) + // end may exceed the length so the dangling-begin path is covered. + end := index + 1 + random.Intn(length+2-index) + out = append(out, markScenarioStep{ + kind: "mark", + index: uint32(index), + end: uint32(end), + name: names[random.Intn(len(names))], + expand: expands[random.Intn(len(expands))], + }) + } + } + + return out +} + +func runMarkScenario( + t *testing.T, + ctx context.Context, + engine rustParityEngine, + steps []markScenarioStep, +) string { + t.Helper() + + document, err := engine.open(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, document) + + text, err := document.CreateText(ctx, "text") + require.NoError(t, err) + + // Out-of-range marks are expected to fail on both engines; the divergence is + // in the spans they leave behind, so step errors are deliberately ignored. + for _, step := range steps { + switch step.kind { + case "insert": + _ = text.Splice(ctx, step.index, 0, step.value) + case "delete": + _ = text.Splice(ctx, step.index, step.count, "") + case "split": + _, _ = text.SplitBlock(ctx, step.index) + case "mark": + _ = text.Mark(ctx, step.index, step.end, step.name, + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, step.expand) + } + } + + _, _ = document.Commit(ctx, "scenario", commitTime) + + spans, err := text.Spans(ctx) + require.NoError(t, err) + + return renderMarkedSpans(spans) +} + +func renderMarkedSpans(spans []automerge.Span) string { + var builder strings.Builder + + for _, span := range spans { + if span.Type == automerge.SpanTypeBlock { + builder.WriteString("|block") + + continue + } + + names := make([]string, 0, len(span.Marks)) + for name := range span.Marks { + names = append(names, name) + } + + sort.Strings(names) + fmt.Fprintf(&builder, "|%q{%s}", span.Text, strings.Join(names, ",")) + } + + return builder.String() +} + +func renderMarkScenario(steps []markScenarioStep) string { + rendered := make([]string, 0, len(steps)) + for _, step := range steps { + rendered = append(rendered, step.String()) + } + + return strings.Join(rendered, " ") +} diff --git a/pkg/automerge/marks_property_parity_test.go b/pkg/automerge/marks_property_parity_test.go new file mode 100644 index 0000000000..b3e86139fa --- /dev/null +++ b/pkg/automerge/marks_property_parity_test.go @@ -0,0 +1,224 @@ +// 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 := range scenarios { + 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 range steps { + 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 range count { + 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 new file mode 100644 index 0000000000..088a02bc00 --- /dev/null +++ b/pkg/automerge/native_backend_test.go @@ -0,0 +1,1119 @@ +// 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 TestPureGoDocument_ReferenceLoadsNativeHistory(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(40)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + text, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Hello 😀")) + _, err = nativeDocument.Commit(ctx, "Create native document", commitTime) + require.NoError(t, err) + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, data, actor(41)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + value, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Hello 😀", value) +} + +func TestPureGoDocument_ExtendsReferenceSnapshot(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.Load(ctx, newBaseDocument(t), actor(42)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + text, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 5, 0, " native")) + _, err = nativeDocument.Commit(ctx, "Extend in Go", commitTime) + require.NoError(t, err) + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, data, actor(43)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + value, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Hello native", value) +} + +func TestPureGoDocument_InsertsInsideReferenceText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.Load(ctx, newBaseDocument(t), actor(46)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + text, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 2, 0, "X")) + _, err = nativeDocument.Commit(ctx, "Insert in Go", commitTime) + require.NoError(t, err) + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, data, actor(47)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + value, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "HeXllo", value) +} + +func TestPureGoDocument_DeletesReferenceText(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.Load(ctx, newBaseDocument(t), actor(48)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + text, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 2, 2, "")) + _, err = nativeDocument.Commit(ctx, "Delete in Go", commitTime) + require.NoError(t, err) + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, data, actor(49)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + value, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Heo", value) +} + +func TestPureGoDocument_EmptySnapshotLoadsInReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(44)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, data, actor(45)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + heads, err := referenceDocument.Heads(ctx) + require.NoError(t, err) + assert.Empty(t, heads) +} + +func TestPureGoDocument_ReusesActorAfterLoad(t *testing.T) { + t.Parallel() + + ctx := context.Background() + actorID := actor(55) + document, err := automerge.New(ctx, actorID) + 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, "First change", commitTime) + require.NoError(t, err) + data, err := document.Save(ctx) + require.NoError(t, err) + + loaded, err := automerge.Load(ctx, data, actorID) + require.NoError(t, err) + closeDocument(t, loaded) + loadedText, err := loaded.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, loadedText.Splice(ctx, 1, 0, "B")) + _, err = loaded.Commit(ctx, "Second change", commitTime) + require.NoError(t, err) + data, err = loaded.Save(ctx) + require.NoError(t, err) + + reference, err := automerge.LoadReference(ctx, data, actor(56)) + require.NoError(t, err) + closeDocument(t, reference) + referenceText, err := reference.Text(ctx, "body") + require.NoError(t, err) + value, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "AB", value) +} + +func TestPureGoDocument_ConcurrentChangesConverge(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base, err := automerge.New(ctx, actor(50)) + 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, "Create body", commitTime) + require.NoError(t, err) + baseData, err := base.Save(ctx) + require.NoError(t, err) + + left, err := automerge.Load(ctx, baseData, actor(51)) + require.NoError(t, err) + closeDocument(t, left) + leftText, err := left.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, leftText.Splice(ctx, 1, 0, "L")) + _, err = left.Commit(ctx, "Edit left", commitTime) + require.NoError(t, err) + + right, err := automerge.Load(ctx, baseData, actor(52)) + require.NoError(t, err) + closeDocument(t, right) + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, rightText.Splice(ctx, 1, 0, "R")) + _, err = right.Commit(ctx, "Edit right", commitTime) + require.NoError(t, err) + + _, err = left.Merge(ctx, right) + require.NoError(t, err) + _, err = right.Merge(ctx, left) + require.NoError(t, err) + + leftValue, err := leftText.String(ctx) + require.NoError(t, err) + rightValue, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, leftValue, rightValue) + + leftHeads, err := left.Heads(ctx) + require.NoError(t, err) + rightHeads, err := right.Heads(ctx) + require.NoError(t, err) + assert.ElementsMatch(t, leftHeads, rightHeads) +} + +func TestPureGoDocument_CursorMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + baseData := newBaseDocument(t) + nativeDocument, err := automerge.Load(ctx, baseData, actor(60)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + nativeCursor, err := nativeText.Cursor(ctx, 2) + require.NoError(t, err) + + referenceDocument, err := automerge.LoadReference(ctx, baseData, actor(61)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + referenceCursor, err := referenceText.Cursor(ctx, 2) + require.NoError(t, err) + assert.Equal(t, referenceCursor, nativeCursor) + + require.NoError(t, nativeText.Splice(ctx, 0, 0, "X")) + position, err := nativeText.CursorPosition(ctx, nativeCursor) + require.NoError(t, err) + assert.Equal(t, uint32(3), position) +} + +func TestPureGoDocument_DeletedCursorMatchesReference(t *testing.T) { + t.Parallel() + + ctx := context.Background() + baseData := newBaseDocument(t) + nativeDocument, err := automerge.Load(ctx, baseData, actor(62)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.Text(ctx, "body") + require.NoError(t, err) + cursor, err := nativeText.Cursor(ctx, 2) + require.NoError(t, err) + require.NoError(t, nativeText.Splice(ctx, 2, 1, "")) + _, err = nativeDocument.Commit(ctx, "Delete cursor target", commitTime) + require.NoError(t, err) + nativePosition, err := nativeText.CursorPosition(ctx, cursor) + require.NoError(t, err) + + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + referenceDocument, err := automerge.LoadReference(ctx, data, actor(63)) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.Text(ctx, "body") + require.NoError(t, err) + referencePosition, err := referenceText.CursorPosition(ctx, cursor) + require.NoError(t, err) + 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.Load(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() + + ctx := context.Background() + left, err := automerge.New(ctx, actor(70)) + require.NoError(t, err) + closeDocument(t, left) + text, err := left.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Native sync")) + _, err = left.Commit(ctx, "Create sync body", commitTime) + require.NoError(t, err) + + right, err := automerge.New(ctx, actor(71)) + require.NoError(t, err) + closeDocument(t, right) + + leftSync, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftSync) + + rightSync, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightSync) + + synchronize(t, leftSync, rightSync) + + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + value, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Native sync", value) +} + +func TestPureGoDocument_SyncWaitsForPeerResponse(t *testing.T) { + t.Parallel() + + ctx := context.Background() + document, err := automerge.New(ctx, actor(78)) + 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, "Wait")) + _, err = document.Commit(ctx, "Create body", commitTime) + require.NoError(t, err) + + state, err := document.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, state) + first, ok, err := state.GenerateMessage(ctx) + require.NoError(t, err) + assert.True(t, ok) + assert.NotEmpty(t, first) + + second, ok, err := state.GenerateMessage(ctx) + require.NoError(t, err) + assert.False(t, ok) + assert.Empty(t, second) +} + +func TestPureGoDocument_SynchronizesWithReferencePeer(t *testing.T) { + t.Parallel() + + ctx := context.Background() + left, err := automerge.New(ctx, actor(72)) + require.NoError(t, err) + closeDocument(t, left) + text, err := left.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "Reference sync")) + _, err = left.Commit(ctx, "Create sync body", commitTime) + require.NoError(t, err) + + right, err := automerge.NewReference(ctx, actor(73)) + require.NoError(t, err) + closeDocument(t, right) + + leftSync, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftSync) + + rightSync, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightSync) + + synchronize(t, leftSync, rightSync) + + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + value, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "Reference sync", value) +} + +func TestPureGoDocument_ReceivesReferenceSync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + left, err := automerge.NewReference(ctx, actor(74)) + require.NoError(t, err) + closeDocument(t, left) + text, err := left.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "From reference")) + _, err = left.Commit(ctx, "Create reference body", commitTime) + require.NoError(t, err) + + right, err := automerge.New(ctx, actor(75)) + require.NoError(t, err) + closeDocument(t, right) + + leftSync, err := left.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, leftSync) + + rightSync, err := right.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, rightSync) + + synchronize(t, leftSync, rightSync) + + rightText, err := right.Text(ctx, "body") + require.NoError(t, err) + value, err := rightText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "From reference", value) +} + +func TestPureGoDocument_RepeatedMixedPeerSync(t *testing.T) { + t.Parallel() + + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(76)) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + _, err = nativeDocument.Commit(ctx, "Create native body", commitTime) + require.NoError(t, err) + + referenceDocument, err := automerge.NewReference(ctx, actor(77)) + 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 iteration := range 20 { + nativeValue, err := nativeText.String(ctx) + require.NoError(t, err) + + nativeOffset := utf16Offsets([]rune(nativeValue)) + require.NoError( + t, + nativeText.Splice( + ctx, + nativeOffset[len(nativeOffset)-1], + 0, + "N", + ), + ) + _, err = nativeDocument.Commit(ctx, "Native edit", commitTime) + require.NoError(t, err) + + referenceValue, err := referenceText.String(ctx) + require.NoError(t, err) + + referenceOffset := utf16Offsets([]rune(referenceValue)) + require.NoError( + t, + referenceText.Splice( + ctx, + referenceOffset[len(referenceOffset)-1], + 0, + "R", + ), + ) + _, err = referenceDocument.Commit(ctx, "Reference edit", commitTime) + 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, "iteration %d", iteration) + } +} + +func TestPureGoDocument_ReferenceEditsWhileMessageInFlight(t *testing.T) { + t.Parallel() + + ctx := context.Background() + client, err := automerge.NewReference(ctx, actor(79)) + require.NoError(t, err) + closeDocument(t, client) + clientText, err := client.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, clientText.Splice(ctx, 0, 0, "A")) + _, err = client.Commit(ctx, "initial", commitTime) + require.NoError(t, err) + + server, err := automerge.New(ctx, actor(80)) + require.NoError(t, err) + closeDocument(t, server) + + clientSync, err := client.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, clientSync) + + serverSync, err := server.NewSyncState(ctx) + require.NoError(t, err) + closeSyncState(t, serverSync) + synchronize(t, clientSync, serverSync) + + require.NoError(t, clientText.Splice(ctx, 1, 0, "B")) + _, err = client.Commit(ctx, "first edit", commitTime) + require.NoError(t, err) + first, ok, err := clientSync.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + + require.NoError(t, clientText.Splice(ctx, 2, 0, "C")) + _, err = client.Commit(ctx, "second edit", commitTime) + require.NoError(t, err) + + require.NoError(t, serverSync.ReceiveMessage(ctx, first)) + ack, ok, err := serverSync.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, clientSync.ReceiveMessage(ctx, ack)) + + second, ok, err := clientSync.GenerateMessage(ctx) + require.NoError(t, err) + require.True(t, ok) + require.NoError(t, serverSync.ReceiveMessage(ctx, second)) + synchronize(t, clientSync, serverSync) + + serverText, err := server.Text(ctx, "body") + require.NoError(t, err) + value, err := serverText.String(ctx) + 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 new file mode 100644 index 0000000000..3788810373 --- /dev/null +++ b/pkg/automerge/native_differential_test.go @@ -0,0 +1,262 @@ +// 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/rand" + "testing" + "time" + "unicode/utf16" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestPureGoDocument_RandomTextParity(t *testing.T) { + t.Parallel() + + const ( + histories = 20 + steps = 30 + ) + + characters := []rune("abcXYZ😀é") + + for history := range histories { + random := rand.New(rand.NewSource(int64(history + 1))) + ctx := context.Background() + nativeDocument, err := automerge.New(ctx, actor(byte(80+history))) + require.NoError(t, err) + closeDocument(t, nativeDocument) + nativeText, err := nativeDocument.CreateText(ctx, "body") + require.NoError(t, err) + + referenceDocument, err := automerge.NewReference(ctx, actor(byte(80+history))) + require.NoError(t, err) + closeDocument(t, referenceDocument) + referenceText, err := referenceDocument.CreateText(ctx, "body") + require.NoError(t, err) + + var model []rune + for step := range steps { + offsets := utf16Offsets(model) + + var ( + index uint32 + deleteCount int32 + insert string + ) + + if len(model) > 0 && random.Intn(3) == 0 { + position := random.Intn(len(model)) + index = offsets[position] + deleteCount = int32(offsets[position+1] - offsets[position]) + model = append(model[:position], model[position+1:]...) + } else { + position := random.Intn(len(model) + 1) + index = offsets[position] + character := characters[random.Intn(len(characters))] + insert = string(character) + + model = append(model, 0) + copy(model[position+1:], model[position:]) + model[position] = character + } + + require.NoError(t, nativeText.Splice(ctx, index, deleteCount, insert)) + require.NoError(t, referenceText.Splice(ctx, index, deleteCount, insert)) + + message := fmt.Sprintf("history %d step %d", history, step) + _, err = nativeDocument.Commit(ctx, message, commitTime) + require.NoError(t, err) + _, err = referenceDocument.Commit(ctx, message, commitTime) + require.NoError(t, err) + + nativeValue, err := nativeText.String(ctx) + require.NoError(t, err) + referenceValue, err := referenceText.String(ctx) + require.NoError(t, err) + assert.Equal(t, string(model), nativeValue) + assert.Equal(t, referenceValue, nativeValue) + } + + data, err := nativeDocument.Save(ctx) + require.NoError(t, err) + loaded, err := automerge.LoadReference(ctx, data, actor(byte(120+history))) + require.NoError(t, err) + closeDocument(t, loaded) + loadedText, err := loaded.Text(ctx, "body") + require.NoError(t, err) + loadedValue, err := loadedText.String(ctx) + require.NoError(t, err) + assert.Equal(t, string(model), loadedValue) + } +} + +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.New( + 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 { + offsets[i+1] = offsets[i] + uint32(len(utf16.Encode([]rune{character}))) + } + + return offsets +} diff --git a/pkg/automerge/object.go b/pkg/automerge/object.go new file mode 100644 index 0000000000..0f6dddc4fa --- /dev/null +++ b/pkg/automerge/object.go @@ -0,0 +1,563 @@ +// 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.engine.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.engine.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.engine.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.engine.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.engine.GetScalarAtHeads( + ctx, + o.handle, + key, + engineHashes(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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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(ctx context.Context) (*Text, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + 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 engineHashes(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/official_battery_fixture_test.go b/pkg/automerge/official_battery_fixture_test.go new file mode 100644 index 0000000000..45d6bd434c --- /dev/null +++ b/pkg/automerge/official_battery_fixture_test.go @@ -0,0 +1,116 @@ +// 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" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +// TestOfficialBenchmarkBatteryFixtures runs the documents generated by the real +// upstream benchmark-battery constructors through Rust -> Go -> Rust and +// Rust -> Go -> Rust -> Go. It is opt-in because the official typing fixture has +// ten thousand changes and is intended for the weekly battery job. +func TestOfficialBenchmarkBatteryFixtures(t *testing.T) { + t.Parallel() + + directory := os.Getenv("AUTOMERGE_OFFICIAL_BATTERY_FIXTURES") + if directory == "" { + t.Skip("AUTOMERGE_OFFICIAL_BATTERY_FIXTURES is not configured") + } + + fixtures, err := filepath.Glob(filepath.Join(directory, "*.automerge")) + require.NoError(t, err) + require.NotEmpty(t, fixtures) + sort.Strings(fixtures) + + for index, fixture := range fixtures { + t.Run(filepath.Base(fixture), func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + data, err := os.ReadFile(fixture) + require.NoError(t, err) + + nativeDocument, err := automerge.Load(ctx, data, actor(byte(0x80+index))) + require.NoError(t, err) + closeDocument(t, nativeDocument) + + referenceDocument, err := automerge.LoadReference( + ctx, + data, + actor(byte(0x90+index)), + ) + require.NoError(t, err) + closeDocument(t, referenceDocument) + + assert.Equal( + t, + sortedHeadHex(t, ctx, referenceDocument), + sortedHeadHex(t, ctx, nativeDocument), + ) + + // Rust fixture -> Go save -> Rust load. + nativeSaved, err := nativeDocument.Save(ctx) + require.NoError(t, err) + + referenceReloaded, err := automerge.LoadReference( + ctx, + nativeSaved, + actor(byte(0xa0+index)), + ) + require.NoError(t, err) + closeDocument(t, referenceReloaded) + + assert.Equal( + t, + sortedHeadHex(t, ctx, referenceDocument), + sortedHeadHex(t, ctx, referenceReloaded), + ) + + // Rust fixture -> Rust save -> Go load. + referenceSaved, err := referenceDocument.Save(ctx) + require.NoError(t, err) + + nativeReloaded, err := automerge.Load( + ctx, + referenceSaved, + actor(byte(0xb0+index)), + ) + require.NoError(t, err) + closeDocument(t, nativeReloaded) + + assert.Equal( + t, + sortedHeadHex(t, ctx, nativeDocument), + sortedHeadHex(t, ctx, nativeReloaded), + ) + }) + } +} 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..233b8f1f2b --- /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.Save(ctx, automerge.DiscardOrphans()) + 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..56139a564c --- /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.engine.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.engine.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.engine.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.engine.Diff(ctx, engineHashes(before), engineHashes(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..2f5239d46a --- /dev/null +++ b/pkg/automerge/prosemirror/fuzz_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. + +package prosemirror_test + +import ( + "encoding/json" + "testing" + + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" + "go.probo.inc/probo/pkg/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.Add([]byte(`[ + {"Type":"block","Block":{"type":"callout","parents":[]}}, + {"Type":"text","Text":"Unknown block"}, + {"Type":"block","Block":{"type":"paragraph","parents":["callout"]}}, + {"Type":"text","Text":"Hoisted child"} + ]`)) + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"paragraph","parents":[]}}, + {"Type":"text","Text":"Marked","Marks":{"highlight":true,"strong":true}} + ]`)) + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"table-cell","parents":[]}}, + {"Type":"text","Text":"Stray cell"} + ]`)) + f.Add([]byte(`[ + {"Type":"block","Block":{"type":"table-row","parents":[]}}, + {"Type":"block","Block":{"type":"table-cell","parents":["table-row"]}}, + {"Type":"text","Text":"Stray row"} + ]`)) + + 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() + } + + content, err := automergeprosemirror.Render(spans) + if err != nil { + t.Fatalf("Render returned an error for JSON-decoded spans: %v", err) + } + + // Rendering must never emit a document the canonical consumers reject: + // those renderers error on unknown or misplaced nodes, so a document that + // fails here would break publishing and export for the whole document. + node, err := prosemirror.Parse(content) + if err != nil { + t.Fatalf("cannot parse rendered document %q: %v", content, err) + } + + if _, err := prosemirror.RenderMarkdown(node); err != nil { + t.Fatalf("rendered document is not valid Markdown %q: %v", content, err) + } + + if _, err := prosemirror.RenderHTML(node); err != nil { + t.Fatalf("rendered document is not valid HTML %q: %v", content, err) + } + }) +} diff --git a/pkg/automerge/prosemirror/mapping_test.go b/pkg/automerge/prosemirror/mapping_test.go new file mode 100644 index 0000000000..f60af12289 --- /dev/null +++ b/pkg/automerge/prosemirror/mapping_test.go @@ -0,0 +1,118 @@ +// 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 + +import ( + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type schemaMappingLedger struct { + Blocks []struct { + Automerge string `json:"automerge"` + ProseMirror string `json:"prosemirror"` + Outer string `json:"outer"` + IsEmbed bool `json:"isEmbed"` + } `json:"blocks"` + Marks []struct { + Automerge string `json:"automerge"` + ProseMirror string `json:"prosemirror"` + } `json:"marks"` +} + +func loadSchemaMappingLedger(t *testing.T) schemaMappingLedger { + t.Helper() + + raw, err := os.ReadFile("testdata/schema-mapping.json") + require.NoError(t, err) + + var ledger schemaMappingLedger + require.NoError(t, json.Unmarshal(raw, &ledger)) + require.NotEmpty(t, ledger.Blocks) + require.NotEmpty(t, ledger.Marks) + + return ledger +} + +// TestSchemaMappingLedger keeps the Go renderer's block and mark tables in lockstep +// with the shared ledger. Its counterpart schemaMappingParity.test.ts holds the +// frontend adapter to the same ledger, so the two implementations cannot drift +// apart without a test failing. +func TestSchemaMappingLedger(t *testing.T) { + t.Parallel() + + ledger := loadSchemaMappingLedger(t) + + t.Run("blocks match", func(t *testing.T) { + t.Parallel() + + want := make(map[string]string, len(ledger.Blocks)) + for _, entry := range ledger.Blocks { + want[entry.Automerge] = entry.ProseMirror + } + + got := make(map[string]string, len(blockMappings)) + for _, mapping := range blockMappings { + got[mapping.Automerge] = mapping.ProseMirror + } + + assert.Equal(t, want, got) + assert.Equal(t, want, blockNodeNames) + }) + + t.Run("marks match", func(t *testing.T) { + t.Parallel() + + want := make(map[string]string, len(ledger.Marks)) + for _, entry := range ledger.Marks { + want[entry.Automerge] = entry.ProseMirror + } + + got := make(map[string]string, len(markMappings)) + for _, mapping := range markMappings { + got[mapping.Automerge] = mapping.ProseMirror + } + + assert.Equal(t, want, got) + assert.Equal(t, want, markNodeNames) + }) + + t.Run("mark order matches schema rank", func(t *testing.T) { + t.Parallel() + + for index, entry := range ledger.Marks { + assert.Equalf( + t, + index, + markRenderOrder[entry.Automerge], + "mark %q must render at schema rank %d", + entry.Automerge, + index, + ) + } + + assert.Len(t, markRenderOrder, len(ledger.Marks)) + }) +} diff --git a/pkg/automerge/prosemirror/parse.go b/pkg/automerge/prosemirror/parse.go new file mode 100644 index 0000000000..c9c0bd859c --- /dev/null +++ b/pkg/automerge/prosemirror/parse.go @@ -0,0 +1,357 @@ +// 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 + +import ( + "encoding/json" + "fmt" + "slices" + + "go.probo.inc/probo/pkg/automerge" +) + +// ToSpans converts a ProseMirror document (the JSON the editor stores) into the +// Automerge rich-text spans that seed a collaboration document. It is the +// forward counterpart of Render: writing the returned spans with +// Text.UpdateSpans and then rendering the document reproduces the input, which +// is what lets the server seed a document version's CRDT from its stored +// ProseMirror content without a JavaScript build step. +// +// The traversal is the inverse of render.go: every block node emits a block +// marker carrying its full ancestor path of Automerge block types; a container +// block (blockquote, list item, table cell) folds its first paragraph's inline +// content into itself, matching how @automerge/prosemirror flattens the tree; +// list wrappers are transparent and only their items become blocks; and marks +// map by the shared schema ledger. Unknown nodes and marks are dropped, mirroring +// the renderer's tolerance for schema drift. +func ToSpans(documentJSON string) ([]automerge.SpanInput, error) { + var document pmNode + if err := json.Unmarshal([]byte(documentJSON), &document); err != nil { + return nil, fmt.Errorf("cannot parse ProseMirror document: %w", err) + } + + if document.Type != "doc" { + return nil, fmt.Errorf("expected a ProseMirror doc node, got %q", document.Type) + } + + spans := make([]automerge.SpanInput, 0) + for _, child := range document.Content { + spans = emitBlock(spans, child, nil) + } + + return spans, nil +} + +// UpdateSpansConfig returns the span-writing configuration the seeder uses. Marks +// expand after their range by default, matching the frontend adapter, so text +// typed at a mark's trailing edge inherits it. +func UpdateSpansConfig() automerge.UpdateSpansConfig { + return automerge.UpdateSpansConfig{DefaultExpand: automerge.MarkExpandAfter} +} + +type ( + pmNode struct { + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` + Content []pmNode `json:"content,omitempty"` + Text string `json:"text,omitempty"` + Marks []pmMark `json:"marks,omitempty"` + } + + pmMark struct { + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` + } +) + +// pmNodeToBlock maps a ProseMirror node name to its Automerge block type. List +// items are absent because their type depends on the enclosing list wrapper. +var pmNodeToBlock = map[string]string{ + "paragraph": blockTypeParagraph, + "heading": blockTypeHeading, + "codeBlock": blockTypeCode, + "blockquote": blockTypeBlockquote, + "horizontalRule": blockTypeHorizontalRule, + "table": blockTypeTable, + "tableRow": blockTypeTableRow, + "tableCell": blockTypeTableCell, + "tableHeader": blockTypeTableHeader, +} + +var pmMarkToAutomerge = map[string]string{} + +func init() { + for _, mapping := range markMappings { + pmMarkToAutomerge[mapping.ProseMirror] = mapping.Automerge + } +} + +func emitBlock(spans []automerge.SpanInput, n pmNode, parents []string) []automerge.SpanInput { + switch n.Type { + case "bulletList", "orderedList": + itemType := blockTypeUnorderedListItem + if n.Type == "orderedList" { + itemType = blockTypeOrderedListItem + } + + for _, item := range n.Content { + if item.Type != "listItem" { + continue + } + + spans = append(spans, blockSpan(itemType, parents, nil)) + // A list item always renders exactly one leading paragraph from its + // own content, so its first paragraph is always folded in. + spans = emitContainerChildren(spans, item.Content, childPath(parents, itemType), true) + } + + return spans + case "paragraph", "heading", "codeBlock": + blockType := pmNodeToBlock[n.Type] + spans = append(spans, blockSpan(blockType, parents, blockAttributes(blockType, n.Attrs))) + + return emitInline(spans, n.Content, childPath(parents, blockType)) + case "blockquote", "tableCell", "tableHeader": + blockType := pmNodeToBlock[n.Type] + spans = append(spans, blockSpan(blockType, parents, blockAttributes(blockType, n.Attrs))) + + // These containers only render a leading paragraph from their own content + // when it is non-empty (or they have no other children), so an empty + // first paragraph with siblings must stay an explicit block. + return emitContainerChildren(spans, n.Content, childPath(parents, blockType), false) + case "table", "tableRow": + blockType := pmNodeToBlock[n.Type] + spans = append(spans, blockSpan(blockType, parents, blockAttributes(blockType, n.Attrs))) + + childParents := childPath(parents, blockType) + for _, child := range n.Content { + spans = emitBlock(spans, child, childParents) + } + + return spans + case "horizontalRule": + return append(spans, blockSpan(blockTypeHorizontalRule, parents, map[string]any{})) + default: + // Unknown block node: drop it rather than abort, mirroring the renderer. + return spans + } +} + +// emitContainerChildren writes the children of a container block. A container's +// first paragraph may be folded into the container's own inline content (no +// separate block marker), which is how list items, blockquotes, and table cells +// carry their first line; any remaining children become nested blocks. +// +// Whether the fold happens matches how the renderer reconstructs the container. +// A list item always renders one leading paragraph from its own content, so its +// first paragraph is always folded (alwaysFoldFirstParagraph). A blockquote or +// table cell only renders a leading paragraph when its own content is non-empty +// or it has no other children, so an empty first paragraph that has siblings +// must remain an explicit block or it would be lost on the round trip. +func emitContainerChildren( + spans []automerge.SpanInput, + children []pmNode, + childParents []string, + alwaysFoldFirstParagraph bool, +) []automerge.SpanInput { + if len(children) > 0 && children[0].Type == "paragraph" { + fold := alwaysFoldFirstParagraph || + len(children) == 1 || + len(children[0].Content) > 0 + if fold { + spans = emitInline(spans, children[0].Content, childParents) + + for _, child := range children[1:] { + spans = emitBlock(spans, child, childParents) + } + + return spans + } + } + + for _, child := range children { + spans = emitBlock(spans, child, childParents) + } + + return spans +} + +func emitInline(spans []automerge.SpanInput, inline []pmNode, blockParents []string) []automerge.SpanInput { + for _, child := range inline { + switch child.Type { + case "text": + if child.Text == "" { + continue + } + + span := automerge.SpanInput{Text: child.Text} + if marks := convertMarks(child.Marks); len(marks) > 0 { + span.Marks = marks + } + + spans = append(spans, span) + case "hardBreak": + spans = append(spans, hardBreakSpan(blockParents)) + default: + continue + } + } + + return spans +} + +func convertMarks(marks []pmMark) map[string]automerge.Scalar { + if len(marks) == 0 { + return nil + } + + result := make(map[string]automerge.Scalar, len(marks)) + + for _, mark := range marks { + name, ok := pmMarkToAutomerge[mark.Type] + if !ok { + continue + } + + if name == "link" { + payload, err := json.Marshal(map[string]any{ + "href": stringAttribute(mark.Attrs, "href"), + "title": nullableStringAttribute(mark.Attrs, "title"), + }) + if err != nil { + continue + } + + result[name] = automerge.StringScalar(string(payload)) + + continue + } + + result[name] = automerge.BoolScalar(true) + } + + return result +} + +func blockSpan(blockType string, parents []string, attrs map[string]any) automerge.SpanInput { + if attrs == nil { + attrs = map[string]any{} + } + + return automerge.SpanInput{ + Block: map[string]any{ + "type": blockType, + "parents": toAnySlice(parents), + "attrs": attrs, + "isEmbed": false, + }, + } +} + +func hardBreakSpan(parents []string) automerge.SpanInput { + return automerge.SpanInput{ + Block: map[string]any{ + "type": blockTypeHardBreak, + "parents": toAnySlice(parents), + "attrs": map[string]any{}, + "isEmbed": true, + }, + } +} + +func blockAttributes(blockType string, attrs map[string]any) map[string]any { + switch blockType { + case blockTypeHeading: + level := intAttribute(attrs, "level", 1) + if level < 1 || level > 6 { + level = 1 + } + + return map[string]any{"level": level} + case blockTypeCode: + if language, ok := attrs["language"].(string); ok { + return map[string]any{"language": language} + } + + return map[string]any{} + case blockTypeTableCell, blockTypeTableHeader: + cellAttrs := map[string]any{ + "colspan": intAttribute(attrs, "colspan", 1), + "rowspan": intAttribute(attrs, "rowspan", 1), + } + + if colwidth := numberSliceAttribute(attrs, "colwidth"); colwidth != nil { + cellAttrs["colwidth"] = colwidth + } + + return cellAttrs + default: + return map[string]any{} + } +} + +func childPath(parents []string, blockType string) []string { + return append(slices.Clone(parents), blockType) +} + +func toAnySlice(values []string) []any { + result := make([]any, len(values)) + for i, value := range values { + result[i] = value + } + + return result +} + +func numberSliceAttribute(attrs map[string]any, name string) []any { + values, ok := attrs[name].([]any) + if !ok { + return nil + } + + result := make([]any, 0, len(values)) + for _, value := range values { + number, ok := value.(float64) + if !ok { + return nil + } + + result = append(result, number) + } + + return result +} + +func stringAttribute(attrs map[string]any, name string) string { + if value, ok := attrs[name].(string); ok { + return value + } + + return "" +} + +func nullableStringAttribute(attrs map[string]any, name string) any { + if value, ok := attrs[name].(string); ok { + return value + } + + return nil +} diff --git a/pkg/automerge/prosemirror/parse_test.go b/pkg/automerge/prosemirror/parse_test.go new file mode 100644 index 0000000000..cd45be6a6d --- /dev/null +++ b/pkg/automerge/prosemirror/parse_test.go @@ -0,0 +1,96 @@ +// 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 ( + "compress/gzip" + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" +) + +// TestToSpans_RoundTripsCanonicalDocuments is the seeding-correctness gate: for +// every document in the shared corpus, converting the canonical ProseMirror JSON +// to spans, writing them into a fresh Automerge document, and rendering it back +// must reproduce the same ProseMirror JSON. This is exactly what server-side +// seeding does, so a green run means the server can bootstrap a document +// version's CRDT from its stored content and materialize it unchanged. +func TestToSpans_RoundTripsCanonicalDocuments(t *testing.T) { + t.Parallel() + + file, err := os.Open("testdata/upstream-render.json.gz") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, file.Close()) }) + + reader, err := gzip.NewReader(file) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reader.Close()) }) + + var fixtures []struct { + Name string `json:"name"` + Expected json.RawMessage `json:"expected"` + } + require.NoError(t, json.NewDecoder(reader).Decode(&fixtures)) + require.NotEmpty(t, fixtures) + + for _, fixture := range fixtures { + t.Run(fixture.Name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + spans, err := automergeprosemirror.ToSpans(string(fixture.Expected)) + require.NoError(t, err) + + actorID, err := automerge.NewActorID() + require.NoError(t, err) + + document, err := automerge.New(ctx, actorID) + require.NoError(t, err) + + defer func() { _ = document.Close(ctx) }() + + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + + require.NoError(t, text.UpdateSpans(ctx, spans, automergeprosemirror.UpdateSpansConfig())) + + readback, err := text.Spans(ctx) + require.NoError(t, err) + + rendered, err := automergeprosemirror.Render(readback) + require.NoError(t, err) + + assert.JSONEq( + t, + string(fixture.Expected), + rendered, + "seeding %s must round-trip through spans", fixture.Name, + ) + }) + } +} diff --git a/pkg/automerge/prosemirror/render.go b/pkg/automerge/prosemirror/render.go new file mode 100644 index 0000000000..d92fef5a97 --- /dev/null +++ b/pkg/automerge/prosemirror/render.go @@ -0,0 +1,652 @@ +// 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 + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + + "go.probo.inc/probo/pkg/automerge" +) + +type schemaMapping struct { + Automerge string + ProseMirror string +} + +// blockMappings and markMappings are the Go half of the shared ProseMirror <-> +// Automerge schema ledger (testdata/schema-mapping.json). Keeping them as data +// lets a drift test assert that this renderer, the frontend adapter, and the +// ledger all agree on block-type strings, mark names, and mark order. markMappings +// is ordered by ProseMirror schema rank because ProseMirror stores marks in that +// order, so the renderer must emit them the same way for byte-identical documents. +var ( + blockMappings = []schemaMapping{ + {Automerge: blockTypeParagraph, ProseMirror: "paragraph"}, + {Automerge: blockTypeHeading, ProseMirror: "heading"}, + {Automerge: blockTypeCode, ProseMirror: "codeBlock"}, + {Automerge: blockTypeBlockquote, ProseMirror: "blockquote"}, + {Automerge: blockTypeOrderedListItem, ProseMirror: "listItem"}, + {Automerge: blockTypeUnorderedListItem, ProseMirror: "listItem"}, + {Automerge: blockTypeHorizontalRule, ProseMirror: "horizontalRule"}, + {Automerge: blockTypeHardBreak, ProseMirror: "hardBreak"}, + {Automerge: blockTypeTable, ProseMirror: "table"}, + {Automerge: blockTypeTableCell, ProseMirror: "tableCell"}, + {Automerge: blockTypeTableHeader, ProseMirror: "tableHeader"}, + {Automerge: blockTypeTableRow, ProseMirror: "tableRow"}, + } + + markMappings = []schemaMapping{ + {Automerge: "link", ProseMirror: "link"}, + {Automerge: "strong", ProseMirror: "bold"}, + {Automerge: "em", ProseMirror: "italic"}, + {Automerge: "strike", ProseMirror: "strike"}, + {Automerge: "underline", ProseMirror: "underline"}, + {Automerge: "code", ProseMirror: "code"}, + } + + blockNodeNames = map[string]string{} + markNodeNames = map[string]string{} + markRenderOrder = map[string]int{} +) + +func init() { + for _, mapping := range blockMappings { + blockNodeNames[mapping.Automerge] = mapping.ProseMirror + } + + for index, mapping := range markMappings { + markNodeNames[mapping.Automerge] = mapping.ProseMirror + markRenderOrder[mapping.Automerge] = index + } +} + +type ( + node struct { + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` + Content []node `json:"content,omitempty"` + Text string `json:"text,omitempty"` + Marks []mark `json:"marks,omitempty"` + } + + mark struct { + Type string `json:"type"` + Attrs map[string]any `json:"attrs,omitempty"` + } + + block struct { + Type string + Parents []string + Attrs map[string]any + Content []node + } +) + +const ( + blockTypeBlockquote = "blockquote" + blockTypeCode = "code-block" + blockTypeHardBreak = "hard-break" + blockTypeHeading = "heading" + blockTypeHorizontalRule = "horizontal-rule" + blockTypeOrderedListItem = "ordered-list-item" + blockTypeParagraph = "paragraph" + blockTypeTable = "table" + blockTypeTableCell = "table-cell" + blockTypeTableHeader = "table-header" + blockTypeTableRow = "table-row" + blockTypeUnorderedListItem = "unordered-list-item" +) + +func Render(spans []automerge.Span) (string, error) { + blocks, err := collectBlocks(spans) + if err != nil { + return "", err + } + + content, consumed, err := renderBlocks(blocks, nil) + if err != nil { + return "", err + } + + if consumed != len(blocks) { + content = append(content, flattenBlocks(blocks[consumed:])...) + } + + if len(content) == 0 { + content = []node{{Type: "paragraph"}} + } + + data, err := json.Marshal(node{Type: "doc", Content: content}) + if err != nil { + return "", fmt.Errorf("cannot marshal ProseMirror document: %w", err) + } + + return string(data), nil +} + +func collectBlocks(spans []automerge.Span) ([]block, error) { + blocks := make([]block, 0) + + for _, span := range spans { + switch span.Type { + case automerge.SpanTypeBlock: + blockType, ok := span.Block["type"].(string) + if !ok || blockType == "" { + blockType = "__unknown__" + } + + if blockType == blockTypeHardBreak { + if len(blocks) == 0 || !acceptsInlineContent(blocks[len(blocks)-1].Type) { + parents := tolerantStringSlice(span.Block["parents"]) + blocks = append( + blocks, + block{ + Type: blockTypeParagraph, + Parents: inlineFallbackParents(parents), + }, + ) + } + + blocks[len(blocks)-1].Content = append( + blocks[len(blocks)-1].Content, + node{Type: blockNodeNames[blockTypeHardBreak]}, + ) + + continue + } + + attrs, _ := span.Block["attrs"].(map[string]any) + blocks = appendNormalizedBlock( + blocks, + block{ + Type: blockType, + Parents: tolerantStringSlice(span.Block["parents"]), + Attrs: attrs, + }, + ) + case automerge.SpanTypeText: + if len(blocks) == 0 || !acceptsInlineContent(blocks[len(blocks)-1].Type) { + var parents []string + if len(blocks) > 0 { + parents = inlineFallbackParents(blocks[len(blocks)-1].Parents) + } + + blocks = appendNormalizedBlock( + blocks, + block{ + Type: blockTypeParagraph, + Parents: parents, + }, + ) + } + + marks, err := renderMarks(span.Marks) + if err != nil { + marks = nil + } + + if span.Text != "" { + blocks[len(blocks)-1].Content = append( + blocks[len(blocks)-1].Content, + node{ + Type: "text", + Text: span.Text, + Marks: marks, + }, + ) + } + default: + continue + } + } + + return blocks, nil +} + +func appendNormalizedBlock(blocks []block, next block) []block { + if len(blocks) == 0 { + next.Parents = nil + + return append(blocks, next) + } + + previous := blocks[len(blocks)-1] + previousPath := append(slices.Clone(previous.Parents), previous.Type) + prefixLength := 0 + + for prefixLength < len(next.Parents) && + prefixLength < len(previousPath) && + next.Parents[prefixLength] == previousPath[prefixLength] { + prefixLength++ + } + + next.Parents = slices.Clone(next.Parents[:prefixLength]) + + return append(blocks, next) +} + +// hoistNodes prepares already-rendered children for a context that is not the one +// they were rendered under. Table parts only mean anything inside a table, so they +// are unwrapped into their own content rather than lifted verbatim. +func hoistNodes(values []node) []node { + result := make([]node, 0, len(values)) + + for _, value := range values { + switch value.Type { + case blockNodeNames[blockTypeTableRow], + blockNodeNames[blockTypeTableCell], + blockNodeNames[blockTypeTableHeader]: + result = append(result, hoistNodes(value.Content)...) + default: + result = append(result, value) + } + } + + return result +} + +// blockAllowedUnder reports whether a block type may appear directly beneath the +// given ancestor path. Table parts only mean anything inside their container, and +// the Markdown and HTML renderers reject them anywhere else, so a stray cell or row +// degrades to its inline content instead of poisoning the whole document. +func blockAllowedUnder(blockType string, parents []string) bool { + var parent string + if len(parents) > 0 { + parent = parents[len(parents)-1] + } + + switch blockType { + case blockTypeTableCell, blockTypeTableHeader: + return parent == blockTypeTableRow + case blockTypeTableRow: + return parent == blockTypeTable + default: + return true + } +} + +func acceptsInlineContent(blockType string) bool { + switch blockType { + case blockTypeHorizontalRule, blockTypeTable, blockTypeTableRow: + return false + default: + return true + } +} + +func inlineFallbackParents(parents []string) []string { + parents = slices.Clone(parents) + + for len(parents) > 0 { + last := parents[len(parents)-1] + if last != blockTypeTable && last != blockTypeTableRow { + break + } + + parents = parents[:len(parents)-1] + } + + return parents +} + +func renderBlocks(blocks []block, parents []string) ([]node, int, error) { + content := make([]node, 0) + consumed := 0 + + for consumed < len(blocks) { + current := blocks[consumed] + if !slices.Equal(current.Parents, parents) { + break + } + + childParents := append(append([]string(nil), parents...), current.Type) + + childEnd := consumed + 1 + for childEnd < len(blocks) && hasPrefix(blocks[childEnd].Parents, childParents) { + childEnd++ + } + + children, childConsumed, err := renderBlocks(blocks[consumed+1:childEnd], childParents) + if err != nil { + return nil, 0, err + } + + if childConsumed != childEnd-consumed-1 { + children = append(children, flattenBlocks(blocks[consumed+1+childConsumed:childEnd])...) + } + + if !blockAllowedUnder(current.Type, parents) { + if len(current.Content) > 0 { + content = append( + content, + node{Type: blockNodeNames[blockTypeParagraph], Content: current.Content}, + ) + } + + content = append(content, hoistNodes(children)...) + consumed = childEnd + + continue + } + + rendered, listType := renderBlock(current, children) + + if listType != "" { + if len(content) > 0 && content[len(content)-1].Type == listType { + content[len(content)-1].Content = append(content[len(content)-1].Content, rendered[0]) + } else { + content = append( + content, + node{ + Type: listType, + Content: []node{rendered[0]}, + }, + ) + } + + content = append(content, rendered[1:]...) + } else { + content = append(content, rendered...) + } + + consumed = childEnd + } + + return content, consumed, nil +} + +func flattenBlocks(blocks []block) []node { + content := make([]node, 0, len(blocks)) + + for _, source := range blocks { + if len(source.Content) > 0 { + content = append( + content, + node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content}, + ) + } + } + + return content +} + +func renderBlock(source block, children []node) ([]node, string) { + switch source.Type { + case blockTypeParagraph: + rendered := node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content} + + return append([]node{rendered}, children...), "" + case blockTypeHeading: + level := intAttribute(source.Attrs, "level", 1) + if level < 1 || level > 6 { + level = 1 + } + + rendered := node{ + Type: blockNodeNames[blockTypeHeading], + Attrs: map[string]any{"level": level}, + Content: source.Content, + } + + return append([]node{rendered}, children...), "" + case blockTypeCode: + attrs := map[string]any{"language": nil} + if language, ok := source.Attrs["language"].(string); ok { + attrs["language"] = language + } + + rendered := node{ + Type: blockNodeNames[blockTypeCode], + Attrs: attrs, + Content: source.Content, + } + + return append([]node{rendered}, children...), "" + case blockTypeHorizontalRule: + rendered := []node{{Type: blockNodeNames[blockTypeHorizontalRule]}} + if len(source.Content) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content}, + ) + } + + return append(rendered, children...), "" + case blockTypeBlockquote: + content := children + if len(source.Content) > 0 || len(children) == 0 { + paragraph := node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content} + content = append([]node{paragraph}, children...) + } + + return []node{{ + Type: blockNodeNames[blockTypeBlockquote], + Content: content, + }}, "" + case blockTypeOrderedListItem: + paragraph := node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content} + + return []node{{ + Type: blockNodeNames[blockTypeOrderedListItem], + Content: append([]node{paragraph}, children...), + }}, "orderedList" + case blockTypeUnorderedListItem: + paragraph := node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content} + + return []node{{ + Type: blockNodeNames[blockTypeUnorderedListItem], + Content: append([]node{paragraph}, children...), + }}, "bulletList" + case blockTypeTable: + rows, spills := partitionNodes(children, blockNodeNames[blockTypeTableRow]) + rendered := make([]node, 0, 2+len(spills)) + + if len(rows) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeTable], Content: rows}, + ) + } + + if len(source.Content) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content}, + ) + } + + return append(rendered, hoistNodes(spills)...), "" + case blockTypeTableRow: + cells, spills := partitionNodes( + children, + blockNodeNames[blockTypeTableCell], + blockNodeNames[blockTypeTableHeader], + ) + rendered := make([]node, 0, 2+len(spills)) + + if len(cells) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeTableRow], Content: cells}, + ) + } + + if len(source.Content) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content}, + ) + } + + return append(rendered, hoistNodes(spills)...), "" + case blockTypeTableCell, blockTypeTableHeader: + content := children + if len(source.Content) > 0 || len(children) == 0 { + paragraph := node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content} + content = append([]node{paragraph}, children...) + } + + attrs := map[string]any{ + "colspan": intAttribute(source.Attrs, "colspan", 1), + "rowspan": intAttribute(source.Attrs, "rowspan", 1), + "colwidth": intSliceAttribute(source.Attrs, "colwidth"), + } + + nodeType := blockNodeNames[blockTypeTableCell] + if source.Type == blockTypeTableHeader { + nodeType = blockNodeNames[blockTypeTableHeader] + } + + return []node{{ + Type: nodeType, + Attrs: attrs, + Content: content, + }}, "" + default: + rendered := make([]node, 0, 1+len(children)) + if len(source.Content) > 0 { + rendered = append( + rendered, + node{Type: blockNodeNames[blockTypeParagraph], Content: source.Content}, + ) + } + + return append(rendered, hoistNodes(children)...), "" + } +} + +func partitionNodes(values []node, allowed ...string) ([]node, []node) { + matches := make([]node, 0, len(values)) + spills := make([]node, 0) + + for _, value := range values { + if slices.Contains(allowed, value.Type) { + matches = append(matches, value) + } else { + spills = append(spills, value) + } + } + + return matches, spills +} + +func renderMarks(values map[string]any) ([]mark, error) { + if len(values) == 0 { + return nil, nil + } + + names := make([]string, 0, len(values)) + + for name := range values { + if _, known := markRenderOrder[name]; !known { + // Unknown marks are dropped rather than aborting the render, mirroring + // the frontend's tolerance for schema drift: the formatting is lost but + // the text survives and downstream renderers only see known marks. + continue + } + + names = append(names, name) + } + + sort.SliceStable(names, func(i, j int) bool { + return markRenderOrder[names[i]] < markRenderOrder[names[j]] + }) + + marks := make([]mark, 0, len(names)) + + for _, name := range names { + if name == "link" { + raw, ok := values[name].(string) + if !ok { + continue + } + + var attrs map[string]any + if err := json.Unmarshal([]byte(raw), &attrs); err != nil { + continue + } + + marks = append(marks, mark{Type: markNodeNames[name], Attrs: attrs}) + + continue + } + + marks = append(marks, mark{Type: markNodeNames[name]}) + } + + if len(marks) == 0 { + return nil, nil + } + + return marks, nil +} + +func tolerantStringSlice(value any) []string { + values, ok := value.([]any) + if !ok { + return nil + } + + result := make([]string, 0, len(values)) + + for _, value := range values { + if item, ok := value.(string); ok && item != "" { + result = append(result, item) + } + } + + return result +} + +func intAttribute(attrs map[string]any, name string, fallback int) int { + value, ok := attrs[name].(float64) + if !ok { + return fallback + } + + return int(value) +} + +func intSliceAttribute(attrs map[string]any, name string) []int { + values, ok := attrs[name].([]any) + if !ok { + return nil + } + + result := make([]int, 0, len(values)) + for _, value := range values { + number, ok := value.(float64) + if !ok { + return nil + } + + result = append(result, int(number)) + } + + return result +} + +func hasPrefix(values, prefix []string) bool { + return len(values) >= len(prefix) && slices.Equal(values[:len(prefix)], prefix) +} diff --git a/pkg/automerge/prosemirror/render_benchmark_test.go b/pkg/automerge/prosemirror/render_benchmark_test.go new file mode 100644 index 0000000000..9268b3d5bd --- /dev/null +++ b/pkg/automerge/prosemirror/render_benchmark_test.go @@ -0,0 +1,149 @@ +// 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 ( + "fmt" + "strings" + "testing" + + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" +) + +func BenchmarkRender(b *testing.B) { + benchmarks := []struct { + name string + spans []automerge.Span + bytes int64 + }{ + { + name: "PlainText10KiB", + spans: []automerge.Span{ + benchmarkBlock("paragraph", nil), + {Type: automerge.SpanTypeText, Text: strings.Repeat("a", 10*1024)}, + }, + bytes: 10 * 1024, + }, + { + name: "ThousandParagraphs100KiB", + spans: benchmarkParagraphs(1_000, 100), + bytes: 100_000, + }, + { + name: "Table100x10x20B", + spans: benchmarkTable(100, 10, 20), + bytes: 20_000, + }, + { + name: "MalformedHierarchy100KiB", + spans: benchmarkMalformed(1_000, 100), + bytes: 100_000, + }, + } + + for _, benchmark := range benchmarks { + b.Run(benchmark.name, func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(benchmark.bytes) + + for b.Loop() { + if _, err := automergeprosemirror.Render(benchmark.spans); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func benchmarkParagraphs(count, textLength int) []automerge.Span { + spans := make([]automerge.Span, 0, count*2) + + for range count { + spans = append( + spans, + benchmarkBlock("paragraph", nil), + automerge.Span{ + Type: automerge.SpanTypeText, + Text: strings.Repeat("a", textLength), + }, + ) + } + + return spans +} + +func benchmarkTable(rows, columns, textLength int) []automerge.Span { + spans := []automerge.Span{benchmarkBlock("table", nil)} + + for row := range rows { + spans = append(spans, benchmarkBlock("table-row", []any{"table"})) + + for column := range columns { + spans = append( + spans, + benchmarkBlock("table-cell", []any{"table", "table-row"}), + automerge.Span{ + Type: automerge.SpanTypeText, + Text: fmt.Sprintf( + "%04d:%02d:%s", + row, + column, + strings.Repeat("a", textLength), + ), + }, + ) + } + } + + return spans +} + +func benchmarkMalformed(count, textLength int) []automerge.Span { + spans := make([]automerge.Span, 0, count*2) + + for index := range count { + spans = append( + spans, + benchmarkBlock( + "paragraph", + []any{"table", "table-row", fmt.Sprintf("missing-%d", index)}, + ), + automerge.Span{ + Type: automerge.SpanTypeText, + Text: strings.Repeat("a", textLength), + }, + ) + } + + return spans +} + +func benchmarkBlock(blockType string, parents []any) automerge.Span { + return automerge.Span{ + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": blockType, + "parents": parents, + "attrs": map[string]any{}, + }, + } +} diff --git a/pkg/automerge/prosemirror/render_test.go b/pkg/automerge/prosemirror/render_test.go new file mode 100644 index 0000000000..470e404709 --- /dev/null +++ b/pkg/automerge/prosemirror/render_test.go @@ -0,0 +1,469 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" + "go.probo.inc/probo/pkg/prosemirror" +) + +func TestRender_RichText(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "heading", + "parents": []any{}, + "attrs": map[string]any{"level": float64(2)}, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "Policy", + Marks: map[string]any{"strong": true}, + }, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + "attrs": map[string]any{}, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "Read ", + }, + { + Type: automerge.SpanTypeText, + Text: "more", + Marks: map[string]any{ + "link": `{"href":"https://example.com","title":"Example"}`, + }, + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": {"level": 2}, + "content": [{ + "type": "text", + "text": "Policy", + "marks": [{"type": "bold"}] + }] + }, + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Read "}, + { + "type": "text", + "text": "more", + "marks": [{ + "type": "link", + "attrs": { + "href": "https://example.com", + "title": "Example" + } + }] + } + ] + } + ] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + require.NoError(t, err) +} + +func TestRender_NestedList(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "unordered-list-item", + "parents": []any{}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Outer"}, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "unordered-list-item", + "parents": []any{"unordered-list-item"}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Inner"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [{ + "type": "bulletList", + "content": [{ + "type": "listItem", + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "Outer"}] + }, + { + "type": "bulletList", + "content": [{ + "type": "listItem", + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": "Inner"}] + }] + }] + } + ] + }] + }] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + require.NoError(t, err) +} + +func TestRender_HardBreakAndHorizontalRule(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + }, + }, + {Type: automerge.SpanTypeText, Text: "A"}, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "hard-break", + "parents": []any{"paragraph"}, + "isEmbed": true, + }, + }, + {Type: automerge.SpanTypeText, Text: "B"}, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "horizontal-rule", + "parents": []any{}, + "isEmbed": true, + }, + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "A"}, + {"type": "hardBreak"}, + {"type": "text", "text": "B"} + ] + }, + {"type": "horizontalRule"} + ] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + 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() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + tableBlock("table", nil, nil), + tableBlock("table-row", []any{"table"}, nil), + tableBlock( + "table-header", + []any{"table", "table-row"}, + map[string]any{ + "colspan": float64(1), + "rowspan": float64(1), + "colwidth": nil, + }, + ), + {Type: automerge.SpanTypeText, Text: "A"}, + tableBlock( + "table-cell", + []any{"table", "table-row"}, + map[string]any{ + "colspan": float64(2), + "rowspan": float64(1), + "colwidth": []any{float64(100), float64(120)}, + }, + ), + {Type: automerge.SpanTypeText, Text: "B"}, + tableBlock("table-row", []any{"table"}, nil), + tableBlock( + "table-cell", + []any{"table", "table-row"}, + map[string]any{ + "colspan": float64(1), + "rowspan": float64(1), + "colwidth": nil, + }, + ), + {Type: automerge.SpanTypeText, Text: "C"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [{ + "type": "table", + "content": [ + { + "type": "tableRow", + "content": [ + { + "type": "tableHeader", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null + }, + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": "A"}] + }] + }, + { + "type": "tableCell", + "attrs": { + "colspan": 2, + "rowspan": 1, + "colwidth": [100, 120] + }, + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": "B"}] + }] + } + ] + }, + { + "type": "tableRow", + "content": [{ + "type": "tableCell", + "attrs": { + "colspan": 1, + "rowspan": 1, + "colwidth": null + }, + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": "C"}] + }] + }] + } + ] + }] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + require.NoError(t, err) +} + +func TestRender_CodeBlockLanguage(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "code-block", + "parents": []any{}, + "attrs": map[string]any{ + "language": "mermaid", + }, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "graph TD; A-->B", + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [{ + "type": "codeBlock", + "attrs": {"language": "mermaid"}, + "content": [{ + "type": "text", + "text": "graph TD; A-->B" + }] + }] + }`, + content, + ) + + _, err = prosemirror.Parse(content) + require.NoError(t, err) +} + +func TestRender_BlockquoteWithExplicitParagraph(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "blockquote", + "parents": []any{}, + }, + }, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{"blockquote"}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Only child"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [{ + "type": "blockquote", + "content": [{ + "type": "paragraph", + "content": [{"type": "text", "text": "Only child"}] + }] + }] + }`, + content, + ) +} + +func tableBlock( + blockType string, + parents []any, + attrs map[string]any, +) automerge.Span { + if attrs == nil { + attrs = map[string]any{} + } + + return automerge.Span{ + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": blockType, + "parents": parents, + "attrs": attrs, + }, + } +} diff --git a/pkg/automerge/prosemirror/render_unknown_test.go b/pkg/automerge/prosemirror/render_unknown_test.go new file mode 100644 index 0000000000..14d392eeba --- /dev/null +++ b/pkg/automerge/prosemirror/render_unknown_test.go @@ -0,0 +1,419 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" + "go.probo.inc/probo/pkg/prosemirror" +) + +// assertCanonicalRenderable proves the rendered JSON only contains node and mark +// types the canonical downstream renderers understand. Those renderers error on +// unknown types, so surviving both is what guarantees an unfamiliar block or mark +// can never abort persistence or publishing. +func assertCanonicalRenderable(t *testing.T, content string) { + t.Helper() + + node, err := prosemirror.Parse(content) + require.NoError(t, err) + + _, err = prosemirror.RenderMarkdown(node) + require.NoError(t, err) + + _, err = prosemirror.RenderHTML(node) + require.NoError(t, err) +} + +func TestRender_UnknownBlockDegradesToParagraph(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "callout", + "parents": []any{}, + "attrs": map[string]any{"variant": "warning"}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Heads up"}, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + }, + }, + {Type: automerge.SpanTypeText, Text: "After"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Heads up"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "After"}]} + ] + }`, + content, + ) + assertCanonicalRenderable(t, content) +} + +func TestRender_UnknownBlockHoistsChildren(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "callout", + "parents": []any{}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Intro"}, + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{"callout"}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Child"}, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Intro"}]}, + {"type": "paragraph", "content": [{"type": "text", "text": "Child"}]} + ] + }`, + content, + ) + assertCanonicalRenderable(t, content) +} + +func TestRender_UnknownMarkIsDropped(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "Text", + Marks: map[string]any{"highlight": true, "strong": true}, + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + {"type": "text", "text": "Text", "marks": [{"type": "bold"}]} + ] + } + ] + }`, + content, + ) + assertCanonicalRenderable(t, content) +} + +func TestRender_MalformedLinkMarkIsDropped(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "Text", + Marks: map[string]any{"link": "not json"}, + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Text"}]} + ] + }`, + content, + ) + assertCanonicalRenderable(t, content) +} + +func TestRender_UnknownMarkOnlyLeavesPlainText(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render( + []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{}, + }, + }, + { + Type: automerge.SpanTypeText, + Text: "Text", + Marks: map[string]any{"highlight": true}, + }, + }, + ) + + require.NoError(t, err) + assert.JSONEq( + t, + `{ + "type": "doc", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Text"}]} + ] + }`, + content, + ) + assertCanonicalRenderable(t, content) +} + +func TestRender_MalformedStructureNeverAborts(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spans []automerge.Span + text string + }{ + { + name: "root hard break", + spans: []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "hard-break", + "parents": []any{}, + "isEmbed": true, + }, + }, + }, + }, + { + name: "missing block type", + spans: []automerge.Span{ + {Type: automerge.SpanTypeBlock, Block: map[string]any{"parents": []any{}}}, + {Type: automerge.SpanTypeText, Text: "Preserved"}, + }, + text: "Preserved", + }, + { + name: "invalid parent value", + spans: []automerge.Span{ + { + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": "paragraph", + "parents": []any{"paragraph", 42, nil}, + }, + }, + {Type: automerge.SpanTypeText, Text: "Rooted"}, + }, + text: "Rooted", + }, + { + name: "paragraph with child block", + spans: []automerge.Span{ + testBlock("paragraph", nil), + {Type: automerge.SpanTypeText, Text: "Parent"}, + testBlock("heading", []any{"paragraph"}), + {Type: automerge.SpanTypeText, Text: "Child"}, + }, + text: "ParentChild", + }, + { + name: "heading with child block", + spans: []automerge.Span{ + testBlock("heading", nil), + {Type: automerge.SpanTypeText, Text: "Heading"}, + testBlock("paragraph", []any{"heading"}), + {Type: automerge.SpanTypeText, Text: "Child"}, + }, + text: "HeadingChild", + }, + { + name: "code block with child block", + spans: []automerge.Span{ + testBlock("code-block", nil), + {Type: automerge.SpanTypeText, Text: "Code"}, + testBlock("paragraph", []any{"code-block"}), + {Type: automerge.SpanTypeText, Text: "Child"}, + }, + text: "CodeChild", + }, + { + name: "table with non-row child", + spans: []automerge.Span{ + testBlock("table", nil), + testBlock("paragraph", []any{"table"}), + {Type: automerge.SpanTypeText, Text: "Hoisted"}, + }, + text: "Hoisted", + }, + { + name: "table row with non-cell child", + spans: []automerge.Span{ + testBlock("table", nil), + testBlock("table-row", []any{"table"}), + testBlock("paragraph", []any{"table", "table-row"}), + {Type: automerge.SpanTypeText, Text: "Hoisted"}, + }, + text: "Hoisted", + }, + { + name: "stray table cell at root", + spans: []automerge.Span{ + testBlock("table-cell", nil), + {Type: automerge.SpanTypeText, Text: "Stray"}, + }, + text: "Stray", + }, + { + name: "stray table header at root", + spans: []automerge.Span{ + testBlock("table-header", nil), + {Type: automerge.SpanTypeText, Text: "Header"}, + }, + text: "Header", + }, + { + name: "stray table row with cells at root", + spans: []automerge.Span{ + testBlock("table-row", nil), + testBlock("table-cell", []any{"table-row"}), + {Type: automerge.SpanTypeText, Text: "Cell"}, + }, + text: "Cell", + }, + { + name: "table cell skipping its row", + spans: []automerge.Span{ + testBlock("table", nil), + testBlock("table-cell", []any{"table"}), + {Type: automerge.SpanTypeText, Text: "Skipped"}, + }, + text: "Skipped", + }, + { + name: "impossible parent chain", + spans: []automerge.Span{ + testBlock("paragraph", nil), + {Type: automerge.SpanTypeText, Text: "First"}, + testBlock("paragraph", []any{"table", "table-row", "table-cell"}), + {Type: automerge.SpanTypeText, Text: "Second"}, + }, + text: "FirstSecond", + }, + { + name: "unknown span type", + spans: []automerge.Span{ + {Type: automerge.SpanType("future")}, + testBlock("paragraph", nil), + {Type: automerge.SpanTypeText, Text: "Known"}, + }, + text: "Known", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + content, err := automergeprosemirror.Render(tt.spans) + require.NoError(t, err) + assertCanonicalRenderable(t, content) + + node, err := prosemirror.Parse(content) + require.NoError(t, err) + assert.Equal(t, tt.text, nodeText(node)) + }) + } +} + +func testBlock(blockType string, parents []any) automerge.Span { + return automerge.Span{ + Type: automerge.SpanTypeBlock, + Block: map[string]any{ + "type": blockType, + "parents": parents, + }, + } +} + +func nodeText(node prosemirror.Node) string { + var text string + if node.Text != nil { + text = *node.Text + } + + for _, child := range node.Content { + text += nodeText(child) + } + + return text +} diff --git a/pkg/automerge/prosemirror/testdata/schema-mapping.json b/pkg/automerge/prosemirror/testdata/schema-mapping.json new file mode 100644 index 0000000000..c3d8d5253e --- /dev/null +++ b/pkg/automerge/prosemirror/testdata/schema-mapping.json @@ -0,0 +1,25 @@ +{ + "_comment": "Shared ProseMirror <-> Automerge schema ledger. Single source of truth for the block-type strings, mark names, and mark order that must agree across the Go renderer (pkg/automerge/prosemirror/render.go), the frontend adapter (packages/ui/src/RichEditor/collaboration.ts), and the render parity oracle. The `marks` array order is the ProseMirror schema rank; the Go renderer must emit marks in this order. Drift-guarded by TestSchemaMappingLedger (Go) and schemaMappingParity.test.ts (frontend). Regenerate the render fixture with `make generate-prosemirror-parity` after any change here.", + "blocks": [ + { "automerge": "paragraph", "prosemirror": "paragraph", "isEmbed": false }, + { "automerge": "heading", "prosemirror": "heading", "isEmbed": false }, + { "automerge": "code-block", "prosemirror": "codeBlock", "isEmbed": false }, + { "automerge": "blockquote", "prosemirror": "blockquote", "isEmbed": false }, + { "automerge": "ordered-list-item", "prosemirror": "listItem", "outer": "orderedList", "isEmbed": false }, + { "automerge": "unordered-list-item", "prosemirror": "listItem", "outer": "bulletList", "isEmbed": false }, + { "automerge": "horizontal-rule", "prosemirror": "horizontalRule", "isEmbed": false }, + { "automerge": "hard-break", "prosemirror": "hardBreak", "isEmbed": true }, + { "automerge": "table", "prosemirror": "table", "isEmbed": false }, + { "automerge": "table-cell", "prosemirror": "tableCell", "isEmbed": false }, + { "automerge": "table-header", "prosemirror": "tableHeader", "isEmbed": false }, + { "automerge": "table-row", "prosemirror": "tableRow", "isEmbed": false } + ], + "marks": [ + { "automerge": "link", "prosemirror": "link" }, + { "automerge": "strong", "prosemirror": "bold" }, + { "automerge": "em", "prosemirror": "italic" }, + { "automerge": "strike", "prosemirror": "strike" }, + { "automerge": "underline", "prosemirror": "underline" }, + { "automerge": "code", "prosemirror": "code" } + ] +} diff --git a/pkg/automerge/prosemirror/testdata/upstream-render.json.gz b/pkg/automerge/prosemirror/testdata/upstream-render.json.gz new file mode 100644 index 0000000000..50b1bee862 Binary files /dev/null and b/pkg/automerge/prosemirror/testdata/upstream-render.json.gz differ diff --git a/pkg/automerge/prosemirror/upstream_render_test.go b/pkg/automerge/prosemirror/upstream_render_test.go new file mode 100644 index 0000000000..2b7c9502b7 --- /dev/null +++ b/pkg/automerge/prosemirror/upstream_render_test.go @@ -0,0 +1,125 @@ +// 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 ( + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" +) + +// TestBridge_MatchesUpstreamInBothDirections is the differential parity gate for +// both halves of the ProseMirror bridge. The fixture is produced by the official +// @automerge/prosemirror library through the real frontend schema adapter (see +// packages/ui/src/RichEditor/prosemirrorRenderParity.test.ts). For each document, +// native Go must materialize the exact spans produced by pmNodeToSpans, and the Go +// renderer must turn those spans back into the canonical ProseMirror JSON produced +// by pmDocFromSpans. +func TestBridge_MatchesUpstreamInBothDirections(t *testing.T) { + t.Parallel() + + file, err := os.Open("testdata/upstream-render.json.gz") + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, file.Close()) }) + + reader, err := gzip.NewReader(file) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, reader.Close()) }) + + var fixtures []struct { + Name string `json:"name"` + Document string `json:"document"` + Expected json.RawMessage `json:"expected"` + Spans json.RawMessage `json:"spans"` + } + require.NoError(t, json.NewDecoder(reader).Decode(&fixtures)) + require.NotEmpty(t, fixtures) + + for _, fixture := range fixtures { + t.Run(fixture.Name, func(t *testing.T) { + t.Parallel() + + data, err := base64.StdEncoding.DecodeString(fixture.Document) + require.NoError(t, err) + + actorID, err := automerge.NewActorID() + require.NoError(t, err) + + document, err := automerge.Load(context.Background(), data, actorID) + require.NoError(t, err) + + defer func() { _ = document.Close(context.Background()) }() + + text, err := document.Text(context.Background(), "body") + require.NoError(t, err) + + spans, err := text.Spans(context.Background()) + require.NoError(t, err) + + actualSpans := make([]map[string]any, 0, len(spans)) + for _, span := range spans { + switch span.Type { + case automerge.SpanTypeBlock: + actualSpans = append( + actualSpans, + map[string]any{ + "type": string(span.Type), + "value": span.Block, + }, + ) + case automerge.SpanTypeText: + actual := map[string]any{ + "type": string(span.Type), + "value": span.Text, + } + if len(span.Marks) > 0 { + actual["marks"] = span.Marks + } + + actualSpans = append(actualSpans, actual) + } + } + + encodedSpans, err := json.Marshal(actualSpans) + require.NoError(t, err) + assert.JSONEq( + t, + string(fixture.Spans), + string(encodedSpans), + "native spans must match pmNodeToSpans for %s", + fixture.Name, + ) + + rendered, err := automergeprosemirror.Render(spans) + require.NoError(t, err) + + assert.JSONEq(t, string(fixture.Expected), rendered) + }) + } +} diff --git a/pkg/automerge/rich_text.go b/pkg/automerge/rich_text.go new file mode 100644 index 0000000000..86fbae5d73 --- /dev/null +++ b/pkg/automerge/rich_text.go @@ -0,0 +1,412 @@ +// 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 ( + SpanType string + + // MarkExpand controls whether inserted text inherits a mark at its edges. + MarkExpand string + + Span struct { + Type SpanType + Text string + Marks map[string]any + Block map[string]any + } + + encodedSpan struct { + Type SpanType `json:"type"` + Value json.RawMessage `json:"value"` + Marks map[string]any `json:"marks"` + } +) + +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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.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.engine.MarksAt(ctx, t.handle, engineHashes(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() + + if t.document.closed { + return nil, ErrClosed + } + + data, err := t.document.engine.TextSpans(ctx, t.handle) + if err != nil { + 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.engine.TextSpansAt(ctx, t.handle, engineHashes(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) + } + + spans := make([]Span, len(encoded)) + for i, source := range encoded { + spans[i].Type = source.Type + spans[i].Marks = source.Marks + + switch source.Type { + case SpanTypeText: + if err := json.Unmarshal(source.Value, &spans[i].Text); err != nil { + return nil, fmt.Errorf("cannot decode Automerge text span %d: %w", i, err) + } + case SpanTypeBlock: + if err := json.Unmarshal(source.Value, &spans[i].Block); err != nil { + return nil, fmt.Errorf("cannot decode Automerge block span %d: %w", i, err) + } + default: + return nil, fmt.Errorf("cannot decode Automerge span %d: unknown type %q", i, source.Type) + } + } + + 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..b4493ab287 --- /dev/null +++ b/pkg/automerge/rich_text_parity_test.go @@ -0,0 +1,685 @@ +// 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 := range 100 { + 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..43bab9b46f --- /dev/null +++ b/pkg/automerge/rollback_parity_test.go @@ -0,0 +1,162 @@ +// 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..f8a14e06bc --- /dev/null +++ b/pkg/automerge/scalar.go @@ -0,0 +1,250 @@ +// 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" +) + +// The constructors below build a Scalar with its type and matching field set +// together, so a caller cannot pair a type with the wrong field. + +// NullScalar returns the null scalar. +func NullScalar() Scalar { return Scalar{Type: ScalarTypeNull} } + +// BoolScalar returns a boolean scalar. +func BoolScalar(value bool) Scalar { return Scalar{Type: ScalarTypeBoolean, Bool: value} } + +// UintScalar returns an unsigned integer scalar. +func UintScalar(value uint64) Scalar { return Scalar{Type: ScalarTypeUint, Uint: value} } + +// IntScalar returns a signed integer scalar. +func IntScalar(value int64) Scalar { return Scalar{Type: ScalarTypeInt, Int: value} } + +// FloatScalar returns a 64-bit floating point scalar. +func FloatScalar(value float64) Scalar { return Scalar{Type: ScalarTypeFloat64, Float: value} } + +// StringScalar returns a string scalar. This stores an immutable string value; +// use a text object for collaboratively editable text. +func StringScalar(value string) Scalar { return Scalar{Type: ScalarTypeString, String: value} } + +// BytesScalar returns a byte string scalar. +func BytesScalar(value []byte) Scalar { return Scalar{Type: ScalarTypeBytes, Bytes: value} } + +// CounterScalar returns a counter scalar, whose value is the sum of every +// increment applied across the history. +func CounterScalar(value int64) Scalar { return Scalar{Type: ScalarTypeCounter, Int: value} } + +// TimestampScalar returns a timestamp scalar carrying milliseconds since the +// Unix epoch. +func TimestampScalar(millis int64) Scalar { return Scalar{Type: ScalarTypeTimestamp, Int: millis} } + +// 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.engine.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.engine.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.engine.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/scalar_constructors_test.go b/pkg/automerge/scalar_constructors_test.go new file mode 100644 index 0000000000..6f9f26139d --- /dev/null +++ b/pkg/automerge/scalar_constructors_test.go @@ -0,0 +1,55 @@ +// 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 ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/automerge" +) + +// TestScalarConstructors verifies each constructor pairs its type with the +// matching field, which is the misuse the plain struct literal invites. +func TestScalarConstructors(t *testing.T) { + t.Parallel() + + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeNull}, automerge.NullScalar()) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, automerge.BoolScalar(true)) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeUint, Uint: 7}, automerge.UintScalar(7)) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeInt, Int: -7}, automerge.IntScalar(-7)) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeFloat64, Float: 1.5}, automerge.FloatScalar(1.5)) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeString, String: "x"}, automerge.StringScalar("x")) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeBytes, Bytes: []byte{1, 2}}, automerge.BytesScalar([]byte{1, 2})) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 5}, automerge.CounterScalar(5)) + assert.Equal(t, automerge.Scalar{Type: automerge.ScalarTypeTimestamp, Int: 1000}, automerge.TimestampScalar(1000)) +} + +// TestActorIDString checks the actor ID renders as lowercase hex like Hash. +func TestActorIDString(t *testing.T) { + t.Parallel() + + var actorID automerge.ActorID + actorID[0] = 0xab + actorID[15] = 0x01 + + assert.Equal(t, "ab000000000000000000000000000001", actorID.String()) +} 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/snapshot_change_graph_test.go b/pkg/automerge/snapshot_change_graph_test.go new file mode 100644 index 0000000000..48e18cc559 --- /dev/null +++ b/pkg/automerge/snapshot_change_graph_test.go @@ -0,0 +1,206 @@ +// 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" +) + +// buildSnapshotHistory authors a multi-commit history exercising the operations +// a snapshot stores differently from a change: marks, whose expand column is +// shared across every change, and deletes, which a snapshot keeps only as +// successor entries on the operations they removed. +func buildSnapshotHistory( + t *testing.T, + ctx context.Context, + document *automerge.Document, +) { + t.Helper() + + base := time.Unix(1786147200, 0).UTC() + + text, err := document.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello brave world")) + _, err = document.Commit(ctx, "write", base) + require.NoError(t, err) + + require.NoError(t, text.Mark( + ctx, + 0, + 5, + "strong", + automerge.Scalar{Type: automerge.ScalarTypeBoolean, Bool: true}, + automerge.MarkExpandBoth, + )) + _, err = document.Commit(ctx, "mark", base.Add(time.Second)) + require.NoError(t, err) + + require.NoError(t, text.Unmark(ctx, 1, 3, "strong", automerge.MarkExpandNone)) + _, err = document.Commit(ctx, "unmark", base.Add(2*time.Second)) + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 5, 6, "")) + _, err = document.Commit(ctx, "delete", base.Add(3*time.Second)) + require.NoError(t, err) + + require.NoError(t, document.PutScalar( + ctx, + "counter", + automerge.Scalar{Type: automerge.ScalarTypeCounter, Int: 5}, + )) + _, err = document.Commit(ctx, "counter", base.Add(4*time.Second)) + require.NoError(t, err) + + require.NoError(t, document.Root().Increment(ctx, "counter", 3)) + _, err = document.Commit(ctx, "increment", base.Add(5*time.Second)) + require.NoError(t, err) +} + +// TestLoadedSnapshotExposesEveryChange is the regression for the production +// outage where collaboration failed with "cannot compute changes from unknown +// heads" on every request for an affected document. +// +// A snapshot records hashes for the frontier only and names ancestry by column +// index, so before the decoder rebuilt them, every non-head change loaded +// without a hash and never entered the change graph. Reading the document still +// worked, which is why the corruption stayed invisible, but walking a head's +// ancestry immediately hit a change that was not there and the walk aborted. +func TestLoadedSnapshotExposesEveryChange(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + reference, err := automerge.NewReference(ctx, actor(11)) + require.NoError(t, err) + closeDocument(t, reference) + + buildSnapshotHistory(t, ctx, reference) + + // The browser client persists exactly this: a document chunk, not a stream of + // change chunks. + snapshot, err := reference.Save(ctx) + require.NoError(t, err) + + referenceHeads, err := reference.Heads(ctx) + require.NoError(t, err) + + loaded, err := automerge.Load(ctx, snapshot, actor(12)) + require.NoError(t, err) + closeDocument(t, loaded) + + loadedHeads, err := loaded.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, referenceHeads, loadedHeads, "snapshot must load onto the same frontier") + + changes, err := loaded.ChangesSince(ctx, nil) + require.NoError(t, err, "every change in a loaded snapshot must be reachable") + assert.Len(t, changes, 6, "each commit must survive as an addressable change") + + // Replaying the rebuilt changes has to land on the same frontier, which only + // holds when each one carries the bytes the original writer hashed. + replayed, err := automerge.New(ctx, actor(13)) + require.NoError(t, err) + closeDocument(t, replayed) + + require.NoError(t, replayed.ApplyChanges(ctx, changes)) + + replayedHeads, err := replayed.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, referenceHeads, replayedHeads, "rebuilt changes must reproduce the frontier") + + // Concatenated change chunks are a document the reference can load, so this + // proves Rust accepts the rebuilt bytes as the changes it originally wrote. + var concatenated []byte + for _, change := range changes { + concatenated = append(concatenated, change.Bytes...) + } + + roundTripped, err := automerge.LoadReference(ctx, concatenated, actor(14)) + require.NoError(t, err) + closeDocument(t, roundTripped) + + roundTrippedHeads, err := roundTripped.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, referenceHeads, roundTrippedHeads) +} + +// TestSnapshotMergeReportsIncrementalChanges reproduces the collaboration +// service's persist path: a canonical document restored from a stored snapshot, +// merged with a peer's document, then asked for the changes the merge added. +func TestSnapshotMergeReportsIncrementalChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + origin, err := automerge.NewReference(ctx, actor(21)) + require.NoError(t, err) + closeDocument(t, origin) + + buildSnapshotHistory(t, ctx, origin) + + snapshot, err := origin.Save(ctx) + require.NoError(t, err) + + canonical, err := automerge.Load(ctx, snapshot, actor(22)) + require.NoError(t, err) + closeDocument(t, canonical) + + peer, err := automerge.Load(ctx, snapshot, actor(23)) + require.NoError(t, err) + closeDocument(t, peer) + + peerText, err := peer.Text(ctx, "body") + require.NoError(t, err) + require.NoError(t, peerText.Splice(ctx, 0, 0, "new ")) + _, err = peer.Commit(ctx, "peer edit", time.Unix(1786147300, 0).UTC()) + require.NoError(t, err) + + before, err := canonical.Heads(ctx) + require.NoError(t, err) + + _, err = canonical.Merge(ctx, peer) + require.NoError(t, err) + + incremental, err := canonical.ChangesSince(ctx, before) + require.NoError(t, err, "merging a peer must not break incremental reads") + assert.Len(t, incremental, 1, "only the peer's commit is new") + + after, err := canonical.Heads(ctx) + require.NoError(t, err) + + peerHeads, err := peer.Heads(ctx) + require.NoError(t, err) + assert.Equal(t, peerHeads, after, "the merge must adopt the peer's frontier") + + canonicalText, err := canonical.Text(ctx, "body") + require.NoError(t, err) + + value, err := canonicalText.String(ctx) + require.NoError(t, err) + assert.Equal(t, "new hello world", value) +} diff --git a/pkg/automerge/sync_chaos_test.go b/pkg/automerge/sync_chaos_test.go new file mode 100644 index 0000000000..0c446aa1ea --- /dev/null +++ b/pkg/automerge/sync_chaos_test.go @@ -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. + +package automerge_test + +import ( + "context" + "fmt" + "math/rand" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +const chaosPeers = 3 + +type syncChaos struct { + documents [chaosPeers]*automerge.Document + states [chaosPeers][chaosPeers]*automerge.SyncState + last [chaosPeers][chaosPeers][]byte +} + +// TestSyncState_ModelBasedChaos combines operations that used to be tested only +// in isolation: concurrent edits, lost and duplicated messages, read-only mode, +// repeated generation without a reply, document persistence, and serialized +// sync-state restoration. Every generated batch must quiesce and a final reliable +// full-mesh delivery must converge all peers. +func TestSyncState_ModelBasedChaos(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + const ( + scenarios = 20 + steps = 120 + ) + + for scenario := range scenarios { + t.Run(fmt.Sprintf("seed-%d", scenario), func(t *testing.T) { + t.Parallel() + + random := rand.New(rand.NewSource(int64(0x51C00000 + scenario))) + chaos := newSyncChaos(t, ctx) + t.Cleanup(func() { chaos.close(ctx) }) + + for step := range steps { + switch random.Intn(8) { + case 0: + chaos.mapEdit(t, ctx, random.Intn(chaosPeers), scenario, step) + case 1: + chaos.textEdit(t, ctx, random, random.Intn(chaosPeers), step) + case 2: + chaos.send(t, ctx, random, true) + case 3: + chaos.send(t, ctx, random, false) + case 4: + chaos.duplicate(t, ctx, random) + case 5: + chaos.toggleReadOnly(t, ctx, random) + case 6: + chaos.reload(t, ctx, random.Intn(chaosPeers)) + case 7: + chaos.assertGenerationQuiesces(t, ctx, random) + } + } + + chaos.converge(t, ctx) + + expected := chaosSignature(t, ctx, chaos.documents[0]) + for peer := 1; peer < chaosPeers; peer++ { + assert.Equalf(t, expected, chaosSignature(t, ctx, chaos.documents[peer]), + "peer %d did not converge", peer) + } + }) + } +} + +func newSyncChaos(t *testing.T, ctx context.Context) *syncChaos { + t.Helper() + + chaos := &syncChaos{} + + seed, err := automerge.New(ctx, actor(0x40)) + require.NoError(t, err) + + body, err := seed.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, body.Splice(ctx, 0, 0, "seed")) + _, err = seed.Commit(ctx, "seed", commitTime) + require.NoError(t, err) + + saved, err := seed.Save(ctx) + require.NoError(t, err) + require.NoError(t, seed.Close(ctx)) + + for peer := range chaosPeers { + chaos.documents[peer], err = automerge.Load(ctx, saved, actor(byte(0x50+peer))) + require.NoError(t, err) + } + + for source := range chaosPeers { + for target := range chaosPeers { + if source == target { + continue + } + + chaos.states[source][target], err = chaos.documents[source].NewSyncState(ctx) + require.NoError(t, err) + } + } + + return chaos +} + +func (c *syncChaos) mapEdit( + t *testing.T, + ctx context.Context, + peer, scenario, step int, +) { + t.Helper() + + key := fmt.Sprintf("p%d-s%d-%d", peer, scenario, step) + require.NoError(t, c.documents[peer].Root().PutScalar( + ctx, + key, + automerge.Scalar{Type: automerge.ScalarTypeInt, Int: int64(step)}, + )) + _, err := c.documents[peer].Commit( + ctx, + "map edit", + commitTime.Add(time.Duration(step)*time.Second), + ) + require.NoError(t, err) +} + +func (c *syncChaos) textEdit( + t *testing.T, + ctx context.Context, + random *rand.Rand, + peer, step int, +) { + t.Helper() + + text, err := c.documents[peer].Text(ctx, "body") + require.NoError(t, err) + + value, err := text.String(ctx) + require.NoError(t, err) + + index := random.Intn(len(value) + 1) + require.NoError(t, text.Splice(ctx, uint32(index), 0, string(rune('a'+peer)))) + + _, err = c.documents[peer].Commit( + ctx, + "text edit", + commitTime.Add(time.Duration(step)*time.Second), + ) + require.NoError(t, err) +} + +func (c *syncChaos) send( + t *testing.T, + ctx context.Context, + random *rand.Rand, + deliver bool, +) { + t.Helper() + + source, target := randomPair(random) + message, ok, err := c.states[source][target].GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + return + } + + c.last[source][target] = append(c.last[source][target][:0], message...) + + if deliver { + require.NoError(t, c.states[target][source].ReceiveMessage(ctx, message)) + } +} + +func (c *syncChaos) duplicate(t *testing.T, ctx context.Context, random *rand.Rand) { + t.Helper() + + source, target := randomPair(random) + + message := c.last[source][target] + if len(message) == 0 { + return + } + + require.NoError(t, c.states[target][source].ReceiveMessage(ctx, message)) + require.NoError(t, c.states[target][source].ReceiveMessage(ctx, message)) +} + +func (c *syncChaos) toggleReadOnly( + t *testing.T, + ctx context.Context, + random *rand.Rand, +) { + t.Helper() + + source, target := randomPair(random) + require.NoError(t, c.states[source][target].SetReadOnly(ctx, random.Intn(2) == 0)) +} + +func (c *syncChaos) reload(t *testing.T, ctx context.Context, peer int) { + t.Helper() + + documentData, err := c.documents[peer].Save(ctx) + require.NoError(t, err) + + var states [chaosPeers][]byte + + for target := range chaosPeers { + if peer == target { + continue + } + + states[target], err = c.states[peer][target].Save(ctx) + require.NoError(t, err) + require.NoError(t, c.states[peer][target].Close(ctx)) + } + + require.NoError(t, c.documents[peer].Close(ctx)) + + c.documents[peer], err = automerge.Load(ctx, documentData, actor(byte(0x50+peer))) + require.NoError(t, err) + + for target := range chaosPeers { + if peer == target { + continue + } + + c.states[peer][target], err = c.documents[peer].LoadSyncState(ctx, states[target]) + require.NoError(t, err) + } +} + +func (c *syncChaos) assertGenerationQuiesces( + t *testing.T, + ctx context.Context, + random *rand.Rand, +) { + t.Helper() + + source, target := randomPair(random) + + for count := range 10 { + message, ok, err := c.states[source][target].GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + return + } + + c.last[source][target] = append(c.last[source][target][:0], message...) + + if count == 9 { + encoded, saveErr := c.states[source][target].Save(ctx) + t.Fatalf( + "peer %d -> %d did not quiesce; state=%s saveErr=%v", + source, + target, + encoded, + saveErr, + ) + } + } +} + +func (c *syncChaos) converge(t *testing.T, ctx context.Context) { + t.Helper() + + for source := range chaosPeers { + for target := range chaosPeers { + if source != target { + require.NoError(t, c.states[source][target].SetReadOnly(ctx, false)) + } + } + } + + for round := range 200 { + sent := false + + for source := range chaosPeers { + for target := range chaosPeers { + if source == target { + continue + } + + message, ok, err := c.states[source][target].GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + continue + } + + sent = true + + require.NoError(t, c.states[target][source].ReceiveMessage(ctx, message)) + } + } + + if !sent { + return + } + + if round == 199 { + t.Fatal("sync chaos peers did not converge") + } + } +} + +func (c *syncChaos) close(ctx context.Context) { + for source := range chaosPeers { + for target := range chaosPeers { + if source != target && c.states[source][target] != nil { + _ = c.states[source][target].Close(ctx) + } + } + + if c.documents[source] != nil { + _ = c.documents[source].Close(ctx) + } + } +} + +func randomPair(random *rand.Rand) (int, int) { + source := random.Intn(chaosPeers) + + target := random.Intn(chaosPeers - 1) + if target >= source { + target++ + } + + return source, target +} + +func chaosSignature(t *testing.T, ctx context.Context, document *automerge.Document) string { + t.Helper() + + heads := sortedHeadHex(t, ctx, document) + + keys, err := document.Root().Keys(ctx) + require.NoError(t, err) + sort.Strings(keys) + + var builder strings.Builder + fmt.Fprintf(&builder, "heads=%v\n", heads) + + for _, key := range keys { + if key == "body" { + continue + } + + value, err := document.Root().Scalar(ctx, key) + require.NoError(t, err) + fmt.Fprintf(&builder, "%s=%s\n", key, canonicalScalar(value)) + } + + text, err := document.Text(ctx, "body") + require.NoError(t, err) + + content, err := text.String(ctx) + require.NoError(t, err) + fmt.Fprintf(&builder, "body=%q", content) + + return builder.String() +} 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..6d1603ed5e --- /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 := range int64(10) { + 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 := range int64(10) { + 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/sync_quiesce_test.go b/pkg/automerge/sync_quiesce_test.go new file mode 100644 index 0000000000..56e2695ec3 --- /dev/null +++ b/pkg/automerge/sync_quiesce_test.go @@ -0,0 +1,168 @@ +// 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" + + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/internal/native" +) + +// drainSyncMessages mirrors the server's sendAvailableSyncMessages: it generates +// messages until the protocol quiesces, bounded so a non-quiescing state fails +// loudly instead of looping forever. +func drainSyncMessages(t *testing.T, ctx context.Context, state *automerge.SyncState) int { + t.Helper() + + for count := range 100 { + _, ok, err := state.GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + return count + } + } + + t.Fatal("sync protocol did not quiesce") + + return 0 +} + +// TestSyncState_QuiescesWithOrphanedChange reproduces the production livelock: a +// document holding an orphaned change (its base never arrived) recomputes a Need +// for the missing base on every received message. Because that Need never +// changed, regenerating it must not keep producing messages, or the server's +// bounded send loop aborts the whole collaboration connection. +func TestSyncState_QuiescesWithOrphanedChange(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Build two dependent changes, then apply only the second so its base is + // missing and it stays queued as an orphan. + source, err := automerge.New(ctx, actor(1)) + require.NoError(t, err) + closeDocument(t, source) + + text, err := source.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "first")) + + base, err := source.Commit(ctx, "base", commitTime) + require.NoError(t, err) + + require.NoError(t, text.Splice(ctx, 5, 0, " second")) + _, err = source.Commit(ctx, "child", commitTime) + require.NoError(t, err) + + childChanges, err := source.ChangesSince(ctx, []automerge.Hash{base}) + require.NoError(t, err) + require.Len(t, childChanges, 1) + + orphanHost, err := automerge.New(ctx, actor(2)) + require.NoError(t, err) + closeDocument(t, orphanHost) + + // Give the host its own history first so the incoming orphan takes the + // change-queue path rather than initializing an empty document. + hostText, err := orphanHost.CreateText(ctx, "host") + require.NoError(t, err) + require.NoError(t, hostText.Splice(ctx, 0, 0, "local")) + _, err = orphanHost.Commit(ctx, "host", commitTime) + require.NoError(t, err) + + // The child change depends on the base the host never received, so it is + // retained as an orphan rather than applied. + require.NoError(t, orphanHost.ApplyChanges(ctx, []automerge.Change{childChanges[0]})) + + // A fresh peer with an empty document handshakes with the orphan host. + peer, err := automerge.New(ctx, actor(3)) + require.NoError(t, err) + closeDocument(t, peer) + + hostState, err := orphanHost.NewSyncState(ctx) + require.NoError(t, err) + + peerState, err := peer.NewSyncState(ctx) + require.NoError(t, err) + + // Exchange a few rounds. Each round the host recomputes the same Need for the + // missing base; the send loop must still quiesce every time. + for round := range 5 { + peerMessage, ok, err := peerState.GenerateMessage(ctx) + require.NoError(t, err) + + if !ok { + break + } + + require.NoError(t, hostState.ReceiveMessage(ctx, peerMessage)) + + sent := drainSyncMessages(t, ctx, hostState) + require.LessOrEqualf(t, sent, 1, + "round %d: host sent %d messages for an unchanging Need", round, sent) + + hostMessage, ok, err := hostState.GenerateMessage(ctx) + require.NoError(t, err) + + if ok { + require.NoError(t, peerState.ReceiveMessage(ctx, hostMessage)) + } + } +} + +// TestSyncState_QuiescesWhenReadOnlyPeerRequestsChanges pins a second +// non-quiescing state found by the model-based chaos test: a peer requests a +// missing change and marks itself read-only in the same message. The sender +// cannot service that request, so retaining it must not make generation loop. +// Once the peer becomes writable it advertises its missing heads again. +func TestSyncState_QuiescesWhenReadOnlyPeerRequestsChanges(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + source, err := automerge.New(ctx, actor(4)) + require.NoError(t, err) + closeDocument(t, source) + + sourceState, err := source.NewSyncState(ctx) + require.NoError(t, err) + + var requested [32]byte + + requested[0] = 1 + + // Model the message found by the chaos test: a peer advertises read-only and + // still carries a stale Need from its previous writable mode. + message, err := (native.SyncMessage{ + Version: native.SyncMessageVersion2, + Need: [][32]byte{requested}, + Flags: []byte{2, 0x80 | 0x02 | 0x04}, + }).Encode() + require.NoError(t, err) + require.NoError(t, sourceState.ReceiveMessage(ctx, message)) + + sent := drainSyncMessages(t, ctx, sourceState) + require.LessOrEqual(t, sent, 1) +} 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..f7bf572986 --- /dev/null +++ b/pkg/automerge/test_rs_core_parity_test.go @@ -0,0 +1,1774 @@ +// 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" + "slices" + "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.LoadOption) (*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 + } + + slices.Sort(result) + + 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.LoadOption) (*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..50cc8b5b60 --- /dev/null +++ b/pkg/automerge/text_encoding_parity_test.go @@ -0,0 +1,730 @@ +// 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..1591d51850 --- /dev/null +++ b/pkg/automerge/update_text_parity_test.go @@ -0,0 +1,138 @@ +// 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(ctx) + 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(ctx) + 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"]) +} diff --git a/pkg/coredata/document_version_automerge_change.go b/pkg/coredata/document_version_automerge_change.go new file mode 100644 index 0000000000..b2b3b08be4 --- /dev/null +++ b/pkg/coredata/document_version_automerge_change.go @@ -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. + +package coredata + +import ( + "context" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + DocumentVersionAutomergeChange struct { + DocumentVersionID gid.GID `db:"document_version_id"` + OrganizationID gid.GID `db:"organization_id"` + Revision int64 `db:"revision"` + ChangeHash []byte `db:"change_hash"` + ChangeBytes []byte `db:"change_bytes"` + CreatedAt time.Time `db:"created_at"` + } + + DocumentVersionAutomergeChanges []*DocumentVersionAutomergeChange +) + +func (c DocumentVersionAutomergeChange) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO document_version_automerge_changes ( + tenant_id, + document_version_id, + organization_id, + revision, + change_hash, + change_bytes, + created_at +) +VALUES ( + @tenant_id, + @document_version_id, + @organization_id, + @revision, + @change_hash, + @change_bytes, + @created_at +) +ON CONFLICT (document_version_id, change_hash) DO NOTHING +` + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "document_version_id": c.DocumentVersionID, + "organization_id": c.OrganizationID, + "revision": c.Revision, + "change_hash": c.ChangeHash, + "change_bytes": c.ChangeBytes, + "created_at": c.CreatedAt, + } + + if _, err := tx.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot insert document version Automerge change: %w", err) + } + + return nil +} + +func (cs *DocumentVersionAutomergeChanges) LoadAfterRevision( + ctx context.Context, + conn pg.Querier, + scope Scoper, + documentVersionID gid.GID, + afterRevision int64, + limit int, +) error { + q := ` +SELECT + document_version_id, + organization_id, + revision, + change_hash, + change_bytes, + created_at +FROM + document_version_automerge_changes +WHERE + %s + AND document_version_id = @document_version_id + AND revision > @after_revision +ORDER BY revision ASC +LIMIT @limit +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "document_version_id": documentVersionID, + "after_revision": afterRevision, + "limit": limit, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query document version Automerge changes: %w", err) + } + + changes, err := pgx.CollectRows( + rows, + pgx.RowToAddrOfStructByName[DocumentVersionAutomergeChange], + ) + if err != nil { + return fmt.Errorf("cannot collect document version Automerge changes: %w", err) + } + + *cs = changes + + return nil +} + +func DeleteDocumentVersionAutomergeChangesThroughRevision( + ctx context.Context, + tx pg.Tx, + scope Scoper, + documentVersionID gid.GID, + revision int64, +) error { + q := ` +DELETE FROM document_version_automerge_changes +WHERE + %s + AND document_version_id = @document_version_id + AND revision <= @revision +` + q = fmt.Sprintf(q, scope.SQLFragment()) + args := pgx.StrictNamedArgs{ + "document_version_id": documentVersionID, + "revision": revision, + } + maps.Copy(args, scope.SQLArguments()) + + if _, err := tx.Exec(ctx, q, args); err != nil { + return fmt.Errorf("cannot delete compacted document version Automerge changes: %w", err) + } + + return nil +} diff --git a/pkg/coredata/document_version_automerge_state.go b/pkg/coredata/document_version_automerge_state.go new file mode 100644 index 0000000000..57ce4df4c3 --- /dev/null +++ b/pkg/coredata/document_version_automerge_state.go @@ -0,0 +1,295 @@ +// 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 coredata + +import ( + "context" + "errors" + "fmt" + "maps" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/gid" +) + +type ( + DocumentVersionAutomergeState struct { + DocumentVersionID gid.GID `db:"document_version_id"` + OrganizationID gid.GID `db:"organization_id"` + Snapshot []byte `db:"snapshot"` + Heads []byte `db:"heads"` + Revision int64 `db:"revision"` + SnapshotRevision int64 `db:"snapshot_revision"` + ChangeRevision int64 `db:"change_revision"` + Seeded bool `db:"seeded"` + SeedClaimedAt *time.Time `db:"seed_claimed_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + } +) + +func (s *DocumentVersionAutomergeState) LoadByDocumentVersionID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + documentVersionID gid.GID, +) error { + return s.loadByDocumentVersionID(ctx, conn, scope, documentVersionID, false) +} + +func (s *DocumentVersionAutomergeState) LoadByDocumentVersionIDForUpdate( + ctx context.Context, + tx pg.Tx, + scope Scoper, + documentVersionID gid.GID, +) error { + return s.loadByDocumentVersionID(ctx, tx, scope, documentVersionID, true) +} + +func (s *DocumentVersionAutomergeState) loadByDocumentVersionID( + ctx context.Context, + conn pg.Querier, + scope Scoper, + documentVersionID gid.GID, + forUpdate bool, +) error { + q := ` +SELECT + document_version_id, + organization_id, + snapshot, + heads, + revision, + snapshot_revision, + change_revision, + seeded, + seed_claimed_at, + created_at, + updated_at +FROM + document_version_automerge_states +WHERE + %s + AND document_version_id = @document_version_id +` + if forUpdate { + q += "FOR UPDATE\n" + } + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "document_version_id": documentVersionID, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query document version Automerge state: %w", err) + } + + state, err := pgx.CollectExactlyOneRow( + rows, + pgx.RowToStructByName[DocumentVersionAutomergeState], + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect document version Automerge state: %w", err) + } + + *s = state + + return nil +} + +func (s DocumentVersionAutomergeState) Insert( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +INSERT INTO document_version_automerge_states ( + tenant_id, + document_version_id, + organization_id, + snapshot, + heads, + revision, + snapshot_revision, + change_revision, + seeded, + seed_claimed_at, + created_at, + updated_at +) +VALUES ( + @tenant_id, + @document_version_id, + @organization_id, + @snapshot, + @heads, + @revision, + @snapshot_revision, + @change_revision, + @seeded, + @seed_claimed_at, + @created_at, + @updated_at +) +` + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "document_version_id": s.DocumentVersionID, + "organization_id": s.OrganizationID, + "snapshot": s.Snapshot, + "heads": s.Heads, + "revision": s.Revision, + "snapshot_revision": s.SnapshotRevision, + "change_revision": s.ChangeRevision, + "seeded": s.Seeded, + "seed_claimed_at": s.SeedClaimedAt, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + + if _, err := tx.Exec(ctx, q, args); err != nil { + if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok { + if pgErr.Code == "23505" && pgErr.ConstraintName == "document_version_automerge_states_pkey" { + return ErrResourceAlreadyExists + } + } + + return fmt.Errorf("cannot insert document version Automerge state: %w", err) + } + + return nil +} + +func (s DocumentVersionAutomergeState) InsertIfAbsent( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) (bool, error) { + q := ` +INSERT INTO document_version_automerge_states ( + tenant_id, + document_version_id, + organization_id, + snapshot, + heads, + revision, + snapshot_revision, + change_revision, + seeded, + seed_claimed_at, + created_at, + updated_at +) +VALUES ( + @tenant_id, + @document_version_id, + @organization_id, + @snapshot, + @heads, + @revision, + @snapshot_revision, + @change_revision, + @seeded, + @seed_claimed_at, + @created_at, + @updated_at +) +ON CONFLICT (document_version_id) DO NOTHING +` + args := pgx.StrictNamedArgs{ + "tenant_id": scope.GetTenantID(), + "document_version_id": s.DocumentVersionID, + "organization_id": s.OrganizationID, + "snapshot": s.Snapshot, + "heads": s.Heads, + "revision": s.Revision, + "snapshot_revision": s.SnapshotRevision, + "change_revision": s.ChangeRevision, + "seeded": s.Seeded, + "seed_claimed_at": s.SeedClaimedAt, + "created_at": s.CreatedAt, + "updated_at": s.UpdatedAt, + } + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return false, fmt.Errorf("cannot insert document version Automerge state if absent: %w", err) + } + + return result.RowsAffected() == 1, nil +} + +func (s DocumentVersionAutomergeState) Update( + ctx context.Context, + tx pg.Tx, + scope Scoper, +) error { + q := ` +UPDATE document_version_automerge_states SET + snapshot = @snapshot, + heads = @heads, + revision = @revision, + snapshot_revision = @snapshot_revision, + change_revision = @change_revision, + seeded = @seeded, + seed_claimed_at = @seed_claimed_at, + updated_at = @updated_at +WHERE + %s + AND document_version_id = @document_version_id +` + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "document_version_id": s.DocumentVersionID, + "snapshot": s.Snapshot, + "heads": s.Heads, + "revision": s.Revision, + "snapshot_revision": s.SnapshotRevision, + "change_revision": s.ChangeRevision, + "seeded": s.Seeded, + "seed_claimed_at": s.SeedClaimedAt, + "updated_at": s.UpdatedAt, + } + maps.Copy(args, scope.SQLArguments()) + + result, err := tx.Exec(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot update document version Automerge state: %w", err) + } + + if result.RowsAffected() == 0 { + return ErrResourceNotFound + } + + return nil +} diff --git a/pkg/coredata/migrations/20260807T125930Z.sql b/pkg/coredata/migrations/20260807T125930Z.sql new file mode 100644 index 0000000000..fde298531c --- /dev/null +++ b/pkg/coredata/migrations/20260807T125930Z.sql @@ -0,0 +1,31 @@ +-- 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. + +CREATE TABLE document_version_automerge_states ( + tenant_id TEXT NOT NULL, + document_version_id TEXT PRIMARY KEY REFERENCES document_versions(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id), + snapshot BYTEA NOT NULL, + revision BIGINT NOT NULL, + seeded BOOLEAN NOT NULL, + seed_claimed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); diff --git a/pkg/coredata/migrations/20260807T135955Z.sql b/pkg/coredata/migrations/20260807T135955Z.sql new file mode 100644 index 0000000000..de15b24f2a --- /dev/null +++ b/pkg/coredata/migrations/20260807T135955Z.sql @@ -0,0 +1,33 @@ +-- 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. + +CREATE TABLE document_version_collaboration_presences ( + tenant_id TEXT NOT NULL, + connection_id TEXT PRIMARY KEY, + document_version_id TEXT NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id), + identity_id TEXT NOT NULL REFERENCES identities(id) ON DELETE CASCADE, + anchor_position INTEGER NOT NULL, + head_position INTEGER NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX document_version_collaboration_presences_version_idx + ON document_version_collaboration_presences (tenant_id, document_version_id, updated_at); diff --git a/pkg/coredata/migrations/20260808T131031Z.sql b/pkg/coredata/migrations/20260808T131031Z.sql new file mode 100644 index 0000000000..1fc39644c7 --- /dev/null +++ b/pkg/coredata/migrations/20260808T131031Z.sql @@ -0,0 +1,58 @@ +-- 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. + +ALTER TABLE document_version_automerge_states + ADD COLUMN snapshot_revision BIGINT; + +ALTER TABLE document_version_automerge_states + ADD COLUMN change_revision BIGINT; + +ALTER TABLE document_version_automerge_states + ADD COLUMN heads BYTEA; + +UPDATE document_version_automerge_states +SET snapshot_revision = revision; + +UPDATE document_version_automerge_states +SET change_revision = revision; + +UPDATE document_version_automerge_states +SET heads = ''::BYTEA; + +ALTER TABLE document_version_automerge_states + ALTER COLUMN snapshot_revision SET NOT NULL; + +ALTER TABLE document_version_automerge_states + ALTER COLUMN change_revision SET NOT NULL; + +ALTER TABLE document_version_automerge_states + ALTER COLUMN heads SET NOT NULL; + +CREATE TABLE document_version_automerge_changes ( + tenant_id TEXT NOT NULL, + document_version_id TEXT NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id), + revision BIGINT NOT NULL, + change_hash BYTEA NOT NULL CHECK (octet_length(change_hash) = 32), + change_bytes BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (document_version_id, change_hash), + UNIQUE (document_version_id, revision) +); diff --git a/pkg/pgnotify/listener.go b/pkg/pgnotify/listener.go new file mode 100644 index 0000000000..6b8597685e --- /dev/null +++ b/pkg/pgnotify/listener.go @@ -0,0 +1,101 @@ +// 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 pgnotify + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/log" +) + +type Listener struct { + config *pgx.ConnConfig + channel string + logger *log.Logger + handler func(string) +} + +const reconnectInterval = time.Second + +func NewListener( + config *pgx.ConnConfig, + channel string, + logger *log.Logger, + handler func(string), +) *Listener { + return &Listener{ + config: config.Copy(), + channel: channel, + logger: logger, + handler: handler, + } +} + +func (l *Listener) Run(ctx context.Context) error { + for { + if err := l.listen(ctx); err != nil { + if ctx.Err() != nil { + return nil + } + + l.logger.WarnCtx( + ctx, + "PostgreSQL notification listener disconnected", + log.Error(err), + ) + } + + timer := time.NewTimer(reconnectInterval) + select { + case <-ctx.Done(): + timer.Stop() + + return nil + case <-timer.C: + } + } +} + +func (l *Listener) listen(ctx context.Context) error { + connection, err := pgx.ConnectConfig(ctx, l.config) + if err != nil { + return fmt.Errorf("cannot connect PostgreSQL notification listener: %w", err) + } + + defer func() { _ = connection.Close(context.Background()) }() + + channel := pgx.Identifier{l.channel}.Sanitize() + if _, err := connection.Exec(ctx, "LISTEN "+channel); err != nil { + return fmt.Errorf("cannot listen for PostgreSQL notifications: %w", err) + } + + for { + notification, err := connection.WaitForNotification(ctx) + if err != nil { + return fmt.Errorf("cannot wait for PostgreSQL notification: %w", err) + } + + l.handler(notification.Payload) + } +} diff --git a/pkg/probo/document_collaboration_service.go b/pkg/probo/document_collaboration_service.go new file mode 100644 index 0000000000..ce2615752a --- /dev/null +++ b/pkg/probo/document_collaboration_service.go @@ -0,0 +1,837 @@ +// 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 probo + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/automerge" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/prosemirror" + "go.probo.inc/probo/pkg/realtime" +) + +const ( + documentCollaborationChangeBatchSize = 1000 + documentCollaborationCompactionChanges = 500 + documentCollaborationSeedLease = 30 * time.Second + documentCollaborationSnapshotMaxBytes = 8 * 1024 * 1024 +) + +type ( + DocumentCollaboration struct { + Document *automerge.Document + Revision int64 + SeedContent string + NeedsSeed bool + } + + ErrDocumentCollaborationStateTooLarge struct { + Size int + } + + DocumentCollaborationTextEdit struct { + ExpectedRevision int64 + Index uint32 + Cursor automerge.Cursor + DeleteCount int32 + Text string + } +) + +var ( + ErrDocumentCollaborationNotSeeded = errors.New("document collaboration is not initialized") + ErrDocumentCollaborationStale = errors.New("document collaboration changed; read it again before editing") +) + +func (e ErrDocumentCollaborationStateTooLarge) Error() string { + return fmt.Sprintf( + "document collaboration state is too large: %d bytes exceeds %d bytes", + e.Size, + documentCollaborationSnapshotMaxBytes, + ) +} + +func (s *DocumentService) OpenCollaboration( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, +) (*DocumentCollaboration, error) { + var ( + snapshot []byte + revision int64 + snapshotRevision int64 + changeRevision int64 + seedContent string + needsSeed bool + ) + + err := s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + version, err := s.loadEditableCollaborationVersion(ctx, tx, scope, documentVersionID) + if err != nil { + return err + } + + seedContent = version.Content + + state := &coredata.DocumentVersionAutomergeState{} + + err = state.LoadByDocumentVersionIDForUpdate(ctx, tx, scope, documentVersionID) + if err == nil { + snapshot = append([]byte(nil), state.Snapshot...) + + needsSeed, err = claimCollaborationSeed(ctx, tx, scope, state, time.Now()) + if err != nil { + return fmt.Errorf("cannot claim document collaboration seed: %w", err) + } + + revision = state.Revision + snapshotRevision = state.SnapshotRevision + changeRevision = state.ChangeRevision + + return nil + } + + if !errors.Is(err, coredata.ErrResourceNotFound) { + return fmt.Errorf("cannot load document collaboration state: %w", err) + } + + document, err := newAutomergeDocument(ctx) + if err != nil { + return fmt.Errorf("cannot create empty collaboration document: %w", err) + } + + defer func() { _ = document.Close(context.Background()) }() + + snapshot, err = document.Save(ctx) + if err != nil { + return fmt.Errorf("cannot save empty collaboration document: %w", err) + } + + now := time.Now() + state = &coredata.DocumentVersionAutomergeState{ + DocumentVersionID: documentVersionID, + OrganizationID: version.OrganizationID, + Snapshot: snapshot, + Heads: encodeAutomergeHeads(nil), + Revision: 1, + SnapshotRevision: 1, + ChangeRevision: 1, + Seeded: false, + SeedClaimedAt: new(now), + CreatedAt: now, + UpdatedAt: now, + } + + inserted, err := state.InsertIfAbsent(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot initialize document collaboration state: %w", err) + } + + if !inserted { + if err := state.LoadByDocumentVersionIDForUpdate( + ctx, + tx, + scope, + documentVersionID, + ); err != nil { + return fmt.Errorf("cannot load concurrently initialized collaboration state: %w", err) + } + + snapshot = append([]byte(nil), state.Snapshot...) + + needsSeed, err = claimCollaborationSeed(ctx, tx, scope, state, now) + if err != nil { + return fmt.Errorf("cannot claim concurrently initialized collaboration seed: %w", err) + } + } else { + needsSeed = true + } + + revision = state.Revision + snapshotRevision = state.SnapshotRevision + changeRevision = state.ChangeRevision + + return nil + }, + ) + if err != nil { + return nil, err + } + + document, err := loadAutomergeDocument(ctx, snapshot) + if err != nil { + return nil, fmt.Errorf("cannot open collaboration snapshot: %w", err) + } + + if err := s.loadCollaborationChanges( + ctx, + scope, + documentVersionID, + document, + snapshotRevision, + changeRevision, + ); err != nil { + _ = document.Close(context.Background()) + + return nil, fmt.Errorf("cannot load collaboration changes: %w", err) + } + + return &DocumentCollaboration{ + Document: document, + Revision: revision, + SeedContent: seedContent, + NeedsSeed: needsSeed, + }, nil +} + +// NotifyCollaborationEphemeral relays an opaque automerge-repo gossip frame +// (presence, cursors) to peers connected to other server instances, over the +// same NOTIFY channel that carries persisted-change signals. The instanceID +// identifies the publishing server so it can ignore its own echo. It is +// fire-and-forget: it does not touch the document and runs outside any +// transaction. An oversized frame returns an error and is not sent; the caller +// still delivers it to its local peers. +func (s *DocumentService) NotifyCollaborationEphemeral( + ctx context.Context, + documentVersionID gid.GID, + instanceID string, + frame []byte, +) error { + payload, err := realtime.EncodeCollaborationEphemeral(realtime.CollaborationEphemeral{ + VersionID: documentVersionID.String(), + InstanceID: instanceID, + Frame: frame, + }) + if err != nil { + return fmt.Errorf("cannot encode collaboration ephemeral: %w", err) + } + + return s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { + if _, err := conn.Exec( + ctx, + `SELECT pg_notify(@channel, @payload)`, + pgx.StrictNamedArgs{ + "channel": realtime.DocumentCollaborationChannel, + "payload": payload, + }, + ); err != nil { + return fmt.Errorf("cannot notify collaboration ephemeral: %w", err) + } + + return nil + }) +} + +func (s *DocumentService) PersistCollaboration( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + document *automerge.Document, +) (int64, error) { + localHeads, err := document.Heads(ctx) + if err != nil { + return 0, fmt.Errorf("cannot read local collaboration heads: %w", err) + } + + localSnapshot, err := document.Save(ctx) + if err != nil { + return 0, fmt.Errorf("cannot save local collaboration document: %w", err) + } + + if len(localSnapshot) > documentCollaborationSnapshotMaxBytes { + return 0, &ErrDocumentCollaborationStateTooLarge{Size: len(localSnapshot)} + } + + var ( + canonicalChangesForLocal []automerge.Change + revision int64 + ) + + err = s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + version, err := s.loadEditableCollaborationVersion( + ctx, + tx, + scope, + documentVersionID, + ) + if err != nil { + return err + } + + state := &coredata.DocumentVersionAutomergeState{} + if err := state.LoadByDocumentVersionIDForUpdate( + ctx, + tx, + scope, + documentVersionID, + ); err != nil { + return fmt.Errorf("cannot lock document collaboration state: %w", err) + } + + canonical, err := loadAutomergeDocument(ctx, state.Snapshot) + if err != nil { + return fmt.Errorf("cannot load canonical collaboration document: %w", err) + } + + defer func() { _ = canonical.Close(context.Background()) }() + + if err := s.loadCollaborationChanges( + ctx, + scope, + documentVersionID, + canonical, + state.SnapshotRevision, + state.ChangeRevision, + ); err != nil { + return fmt.Errorf("cannot load canonical collaboration changes: %w", err) + } + + local, err := loadAutomergeDocument(ctx, localSnapshot) + if err != nil { + return fmt.Errorf("cannot load local collaboration document: %w", err) + } + + defer func() { _ = local.Close(context.Background()) }() + + before, err := canonical.Heads(ctx) + if err != nil { + return fmt.Errorf("cannot read canonical collaboration heads: %w", err) + } + + if _, err := canonical.Merge(ctx, local); err != nil { + return fmt.Errorf("cannot merge collaboration changes: %w", err) + } + + after, err := canonical.Heads(ctx) + if err != nil { + return fmt.Errorf("cannot read merged collaboration heads: %w", err) + } + + incrementalChanges, err := canonical.ChangesSince(ctx, before) + if err != nil { + return fmt.Errorf("cannot read merged collaboration changes: %w", err) + } + + localChanges, err := canonical.ChangesSince(ctx, localHeads) + if err != nil { + return fmt.Errorf("cannot read canonical changes for local document: %w", err) + } + + canonicalChangesForLocal = localChanges + + seeded := state.Seeded || len(after) > 0 + if !slices.Equal(before, after) || seeded != state.Seeded { + now := time.Now() + nextChangeRevision := state.ChangeRevision + + for _, change := range incrementalChanges { + nextChangeRevision++ + + storedChange := coredata.DocumentVersionAutomergeChange{ + DocumentVersionID: documentVersionID, + OrganizationID: version.OrganizationID, + Revision: nextChangeRevision, + ChangeHash: append([]byte(nil), change.Hash[:]...), + ChangeBytes: change.Bytes, + CreatedAt: now, + } + if err := storedChange.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf( + "cannot append document collaboration change: %w", + err, + ) + } + } + + state.Revision++ + state.ChangeRevision = nextChangeRevision + state.Heads = encodeAutomergeHeads(after) + + if state.ChangeRevision-state.SnapshotRevision >= + documentCollaborationCompactionChanges { + snapshot, err := canonical.Save(ctx) + if err != nil { + return fmt.Errorf("cannot compact collaboration snapshot: %w", err) + } + + if len(snapshot) > documentCollaborationSnapshotMaxBytes { + return &ErrDocumentCollaborationStateTooLarge{Size: len(snapshot)} + } + + state.Snapshot = snapshot + state.SnapshotRevision = state.ChangeRevision + + if err := coredata.DeleteDocumentVersionAutomergeChangesThroughRevision( + ctx, + tx, + scope, + documentVersionID, + state.SnapshotRevision, + ); err != nil { + return fmt.Errorf("cannot delete compacted collaboration changes: %w", err) + } + } + + state.Seeded = seeded + if seeded { + state.SeedClaimedAt = nil + } + + state.UpdatedAt = now + if err := state.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot persist document collaboration state: %w", err) + } + + if _, err := tx.Exec( + ctx, + `SELECT pg_notify(@channel, @payload)`, + pgx.StrictNamedArgs{ + "channel": realtime.DocumentCollaborationChannel, + "payload": documentVersionID.String(), + }, + ); err != nil { + return fmt.Errorf("cannot notify document collaboration change: %w", err) + } + + if seeded { + content, err := materializeCollaboration(ctx, canonical) + if err != nil { + return fmt.Errorf("cannot materialize document collaboration: %w", err) + } + + req := UpdateDocumentRequest{ + DocumentID: version.DocumentID, + Content: new(content), + } + if err := req.Validate(); err != nil { + return fmt.Errorf("cannot validate materialized collaboration: %w", err) + } + + version.Content = content + + version.UpdatedAt = now + if err := version.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update materialized document version: %w", err) + } + } + } + + revision = state.Revision + + return nil + }, + ) + if err != nil { + return 0, err + } + + if err := document.ApplyChanges(ctx, canonicalChangesForLocal); err != nil { + return 0, fmt.Errorf("cannot refresh local collaboration document: %w", err) + } + + return revision, nil +} + +func (s *DocumentService) ReleaseCollaborationSeed( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, +) error { + return s.svc.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + state := &coredata.DocumentVersionAutomergeState{} + if err := state.LoadByDocumentVersionIDForUpdate( + ctx, + tx, + scope, + documentVersionID, + ); err != nil { + return fmt.Errorf("cannot lock document collaboration seed: %w", err) + } + + if state.Seeded || state.SeedClaimedAt == nil { + return nil + } + + state.SeedClaimedAt = nil + state.Revision++ + + state.UpdatedAt = time.Now() + if err := state.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot release document collaboration seed: %w", err) + } + + return nil + }, + ) +} + +func (s *DocumentService) RefreshCollaboration( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + document *automerge.Document, + knownRevision int64, +) (int64, bool, error) { + state := &coredata.DocumentVersionAutomergeState{} + + err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + if err := state.LoadByDocumentVersionID( + ctx, + conn, + scope, + documentVersionID, + ); err != nil { + return fmt.Errorf("cannot load document collaboration state: %w", err) + } + + return nil + }, + ) + if err != nil { + return 0, false, err + } + + if state.Revision == knownRevision { + return knownRevision, false, nil + } + + canonical, err := loadAutomergeDocument(ctx, state.Snapshot) + if err != nil { + return 0, false, fmt.Errorf("cannot load refreshed collaboration document: %w", err) + } + + defer func() { _ = canonical.Close(context.Background()) }() + + if err := s.loadCollaborationChanges( + ctx, + scope, + documentVersionID, + canonical, + state.SnapshotRevision, + state.ChangeRevision, + ); err != nil { + return 0, false, fmt.Errorf("cannot load refreshed collaboration changes: %w", err) + } + + if _, err := document.Merge(ctx, canonical); err != nil { + return 0, false, fmt.Errorf("cannot merge refreshed collaboration document: %w", err) + } + + return state.Revision, true, nil +} + +func (s *DocumentService) ApplyCollaborationTextEdit( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + edit DocumentCollaborationTextEdit, +) (int64, error) { + if edit.DeleteCount < 0 { + return 0, fmt.Errorf("collaboration text delete count cannot be negative") + } + + collaboration, err := s.OpenCollaboration(ctx, scope, documentVersionID) + if err != nil { + return 0, fmt.Errorf("cannot open collaboration for text edit: %w", err) + } + + defer func() { _ = collaboration.Document.Close(context.Background()) }() + + if collaboration.NeedsSeed { + return 0, ErrDocumentCollaborationNotSeeded + } + + if edit.ExpectedRevision != collaboration.Revision { + return 0, ErrDocumentCollaborationStale + } + + text, err := collaboration.Document.Text(ctx, "body") + if err != nil { + return 0, fmt.Errorf("cannot get collaboration body: %w", err) + } + + index := edit.Index + if len(edit.Cursor) > 0 { + index, err = text.CursorPosition(ctx, edit.Cursor) + if err != nil { + return 0, fmt.Errorf("cannot resolve collaboration text cursor: %w", err) + } + } + + if err := text.Splice(ctx, index, edit.DeleteCount, edit.Text); err != nil { + return 0, fmt.Errorf("cannot apply collaboration text edit: %w", err) + } + + content, err := text.String(ctx) + if err != nil { + return 0, fmt.Errorf("cannot read edited collaboration body: %w", err) + } + + if utf8.RuneCountInString(content) > documentContentMaxTextLength { + return 0, fmt.Errorf( + "collaboration text exceeds maximum length of %d characters", + documentContentMaxTextLength, + ) + } + + if _, err := collaboration.Document.Commit(ctx, "Agent edit", time.Now()); err != nil { + return 0, fmt.Errorf("cannot commit collaboration text edit: %w", err) + } + + revision, err := s.PersistCollaboration( + ctx, + scope, + documentVersionID, + collaboration.Document, + ) + if err != nil { + return 0, fmt.Errorf("cannot persist collaboration text edit: %w", err) + } + + return revision, nil +} + +func (s *DocumentService) ReadCollaborationText( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, +) (string, int64, error) { + collaboration, err := s.OpenCollaboration(ctx, scope, documentVersionID) + if err != nil { + return "", 0, fmt.Errorf("cannot open collaboration for reading: %w", err) + } + + defer func() { _ = collaboration.Document.Close(context.Background()) }() + + if collaboration.NeedsSeed { + return "", 0, ErrDocumentCollaborationNotSeeded + } + + text, err := collaboration.Document.Text(ctx, "body") + if err != nil { + return "", 0, fmt.Errorf("cannot get collaboration body for reading: %w", err) + } + + content, err := text.String(ctx) + if err != nil { + return "", 0, fmt.Errorf("cannot read collaboration body: %w", err) + } + + return content, collaboration.Revision, nil +} + +func (s *DocumentService) loadEditableCollaborationVersion( + ctx context.Context, + conn pg.Querier, + scope coredata.Scoper, + documentVersionID gid.GID, +) (*coredata.DocumentVersion, error) { + version := &coredata.DocumentVersion{} + if err := version.LoadByID(ctx, conn, scope, documentVersionID); err != nil { + return nil, fmt.Errorf("cannot load collaboration document version: %w", err) + } + + if version.Status != coredata.DocumentVersionStatusDraft { + return nil, &ErrDocumentVersionNotDraft{} + } + + document := &coredata.Document{} + if err := document.LoadByID(ctx, conn, scope, version.DocumentID); err != nil { + return nil, fmt.Errorf("cannot load collaboration document: %w", err) + } + + if document.ArchivedAt != nil { + return nil, &ErrDocumentArchived{} + } + + if document.WriteMode == coredata.DocumentWriteModeGenerated { + return nil, &ErrDocumentVersionGenerated{} + } + + return version, nil +} + +func newAutomergeDocument(ctx context.Context) (*automerge.Document, error) { + actorID, err := automerge.NewActorID() + if err != nil { + return nil, fmt.Errorf("cannot generate collaboration actor ID: %w", err) + } + + document, err := automerge.New(ctx, actorID) + if err != nil { + return nil, fmt.Errorf("cannot create Automerge document: %w", err) + } + + return document, nil +} + +func loadAutomergeDocument(ctx context.Context, snapshot []byte) (*automerge.Document, error) { + actorID, err := automerge.NewActorID() + if err != nil { + return nil, fmt.Errorf("cannot generate collaboration actor ID: %w", err) + } + + document, err := automerge.Load(ctx, snapshot, actorID) + if err != nil { + return nil, fmt.Errorf("cannot load Automerge document: %w", err) + } + + return document, nil +} + +func (s *DocumentService) loadCollaborationChanges( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + document *automerge.Document, + snapshotRevision int64, + latestRevision int64, +) error { + currentRevision := snapshotRevision + + for currentRevision < latestRevision { + var batch coredata.DocumentVersionAutomergeChanges + + if err := s.svc.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + return batch.LoadAfterRevision( + ctx, + conn, + scope, + documentVersionID, + currentRevision, + documentCollaborationChangeBatchSize, + ) + }, + ); err != nil { + return fmt.Errorf("cannot load collaboration change batch: %w", err) + } + + if len(batch) == 0 { + return fmt.Errorf( + "collaboration change log ends at revision %d, expected %d", + currentRevision, + latestRevision, + ) + } + + changes := make([]automerge.Change, len(batch)) + for i, change := range batch { + if change.Revision != currentRevision+1 { + return fmt.Errorf( + "collaboration change revision is %d, expected %d", + change.Revision, + currentRevision+1, + ) + } + + changes[i] = automerge.Change{Bytes: change.ChangeBytes} + currentRevision = change.Revision + } + + if err := document.ApplyChanges(ctx, changes); err != nil { + return fmt.Errorf("cannot apply collaboration change batch: %w", err) + } + } + + return nil +} + +func encodeAutomergeHeads(heads []automerge.Hash) []byte { + encoded := make([]byte, 0, len(heads)*32) + for _, head := range heads { + encoded = append(encoded, head[:]...) + } + + return encoded +} + +func claimCollaborationSeed( + ctx context.Context, + tx pg.Tx, + scope coredata.Scoper, + state *coredata.DocumentVersionAutomergeState, + now time.Time, +) (bool, error) { + if state.Seeded { + return false, nil + } + + if state.SeedClaimedAt != nil && state.SeedClaimedAt.After(now.Add(-documentCollaborationSeedLease)) { + return false, nil + } + + state.SeedClaimedAt = new(now) + state.Revision++ + + state.UpdatedAt = now + if err := state.Update(ctx, tx, scope); err != nil { + return false, fmt.Errorf("cannot update document collaboration seed claim: %w", err) + } + + return true, nil +} + +func materializeCollaboration( + ctx context.Context, + document *automerge.Document, +) (string, error) { + text, err := document.Text(ctx, "body") + if err != nil { + return "", fmt.Errorf("cannot get collaboration body: %w", err) + } + + spans, err := text.Spans(ctx) + if err != nil { + return "", fmt.Errorf("cannot get collaboration spans: %w", err) + } + + content, err := automergeprosemirror.Render(spans) + if err != nil { + return "", fmt.Errorf("cannot render collaboration spans: %w", err) + } + + content, err = prosemirror.SanitizeDocumentJSON(content) + if err != nil { + return "", fmt.Errorf("cannot sanitize collaboration content: %w", err) + } + + return content, nil +} diff --git a/pkg/probo/document_collaboration_service_test.go b/pkg/probo/document_collaboration_service_test.go new file mode 100644 index 0000000000..bf5a94407a --- /dev/null +++ b/pkg/probo/document_collaboration_service_test.go @@ -0,0 +1,53 @@ +// 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 probo + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" +) + +func TestEncodeAutomergeHeads_EmptyIsNotNull(t *testing.T) { + t.Parallel() + + encoded := encodeAutomergeHeads(nil) + + require.NotNil(t, encoded) + assert.Empty(t, encoded) +} + +func TestEncodeAutomergeHeads_ConcatenatesHashes(t *testing.T) { + t.Parallel() + + var first, second automerge.Hash + + first[0] = 1 + second[31] = 2 + + encoded := encodeAutomergeHeads([]automerge.Hash{first, second}) + + require.Len(t, encoded, 64) + assert.Equal(t, byte(1), encoded[0]) + assert.Equal(t, byte(2), encoded[63]) +} diff --git a/pkg/probo/document_collaboration_tools.go b/pkg/probo/document_collaboration_tools.go new file mode 100644 index 0000000000..95001aa8ae --- /dev/null +++ b/pkg/probo/document_collaboration_tools.go @@ -0,0 +1,116 @@ +// 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 probo + +import ( + "context" + "encoding/base64" + "fmt" + + "go.probo.inc/probo/pkg/agent" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" +) + +type ( + readLiveDocumentParams struct{} + + readLiveDocumentResult struct { + Text string `json:"text"` + Revision int64 `json:"revision"` + } + + editLiveDocumentParams struct { + ExpectedRevision int64 `json:"expected_revision" jsonschema:"Revision returned by read_live_document. The edit is rejected if the document changed."` + Index uint32 `json:"index" jsonschema:"UTF-16 sequence position at which to apply the edit."` + Cursor string `json:"cursor,omitempty" jsonschema:"Optional base64 stable Automerge cursor. When provided it takes precedence over index."` + DeleteCount int32 `json:"delete_count" jsonschema:"Number of UTF-16 sequence units to delete."` + Text string `json:"text" jsonschema:"Text to insert at the edit position."` + } + + editLiveDocumentResult struct { + Revision int64 `json:"revision"` + } +) + +func (s *DocumentService) CollaborationTools( + scope coredata.Scoper, + documentVersionID gid.GID, +) []agent.Tool { + return []agent.Tool{ + agent.FunctionTool( + "read_live_document", + "Read the current collaborative document text and revision before proposing an edit.", + func(ctx context.Context, _ readLiveDocumentParams) (agent.ToolResult, error) { + text, revision, err := s.ReadCollaborationText( + ctx, + scope, + documentVersionID, + ) + if err != nil { + return agent.ToolResult{}, fmt.Errorf("cannot read live document: %w", err) + } + + return agent.ResultJSON( + readLiveDocumentResult{ + Text: text, + Revision: revision, + }, + ), nil + }, + ), + agent.FunctionTool( + "edit_live_document_text", + "Edit the collaborative document using the revision returned by read_live_document. Re-read and retry if the document changed concurrently.", + func(ctx context.Context, params editLiveDocumentParams) (agent.ToolResult, error) { + var cursor automerge.Cursor + + if params.Cursor != "" { + decoded, err := base64.StdEncoding.DecodeString(params.Cursor) + if err != nil { + return agent.ToolResult{}, fmt.Errorf("cannot decode live document cursor: %w", err) + } + + cursor = automerge.Cursor(decoded) + } + + revision, err := s.ApplyCollaborationTextEdit( + ctx, + scope, + documentVersionID, + DocumentCollaborationTextEdit{ + ExpectedRevision: params.ExpectedRevision, + Index: params.Index, + Cursor: cursor, + DeleteCount: params.DeleteCount, + Text: params.Text, + }, + ) + if err != nil { + return agent.ToolResult{}, fmt.Errorf("cannot edit live document: %w", err) + } + + return agent.ResultJSON(editLiveDocumentResult{Revision: revision}), nil + }, + ), + } +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index d752c0eaaa..899d3001fb 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -74,7 +74,9 @@ import ( "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailer" "go.probo.inc/probo/pkg/mailman" + "go.probo.inc/probo/pkg/pgnotify" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" @@ -269,6 +271,20 @@ func (impl *Implm) Run( return fmt.Errorf("cannot create pg client: %w", err) } + collaborationEvents := realtime.NewEvents() + + notificationConfig, err := impl.cfg.Pg.ConnectionConfig() + if err != nil { + return fmt.Errorf("cannot configure PostgreSQL notifications: %w", err) + } + + collaborationListener := pgnotify.NewListener( + notificationConfig, + realtime.DocumentCollaborationChannel, + l.Named("document-collaboration-listener"), + collaborationEvents.Publish, + ) + pepper, err := impl.cfg.Auth.GetPepperBytes() if err != nil { rootSpan.RecordError(err) @@ -787,30 +803,35 @@ func (impl *Implm) Run( l.Named("itam"), ) + apiServerCtx, stopApiServer := context.WithCancel(context.Background()) + defer stopApiServer() + serverHandler, err := server.NewServer( server.Config{ - AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, - ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields, - Probo: proboService, - ResourceAlias: resourceAliasService, - File: fileManagerService, - IAM: iamService, - Visitor: visitorService, - ESign: esignService, - Management: managementService, - CertManager: certManagerService, - AccessReview: accessReviewService, - AgentRun: agentRunService, - Mailman: mailmanService, - CookieBanner: cookieBannerService, - Geoloc: geolocService, - ThirdParty: thirdPartyService, - RiskManagement: riskManagementService, - ITAM: itamService, - Slack: slackService, - ConnectorRegistry: defaultConnectorRegistry, - ProviderRegistry: providerRegistry, - BaseURL: baseURL, + AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins, + ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields, + Probo: proboService, + CollaborationEvents: collaborationEvents, + ShutdownContext: apiServerCtx, + ResourceAlias: resourceAliasService, + File: fileManagerService, + IAM: iamService, + Visitor: visitorService, + ESign: esignService, + Management: managementService, + CertManager: certManagerService, + AccessReview: accessReviewService, + AgentRun: agentRunService, + Mailman: mailmanService, + CookieBanner: cookieBannerService, + Geoloc: geolocService, + ThirdParty: thirdPartyService, + RiskManagement: riskManagementService, + ITAM: itamService, + Slack: slackService, + ConnectorRegistry: defaultConnectorRegistry, + ProviderRegistry: providerRegistry, + BaseURL: baseURL, GraphQLLimits: gqlutils.Limits{ ParserTokenLimit: impl.cfg.Api.GraphQL.ParserTokenLimit, ComplexityLimit: impl.cfg.Api.GraphQL.ComplexityLimit, @@ -853,8 +874,16 @@ func (impl *Implm) Run( return fmt.Errorf("cannot create compliance portal handler: %w", err) } - apiServerCtx, stopApiServer := context.WithCancel(context.Background()) - defer stopApiServer() + notificationCtx, stopNotifications := context.WithCancel(context.Background()) + defer stopNotifications() + + wg.Go( + func() { + if err := collaborationListener.Run(notificationCtx); err != nil { + cancel(fmt.Errorf("document collaboration listener crashed: %w", err)) + } + }, + ) wg.Go( func() { @@ -1220,6 +1249,7 @@ func (impl *Implm) Run( <-ctx.Done() stopApiServer() + stopNotifications() stopCompliancePortalServer() stopWebhookWorker() stopESignService() diff --git a/pkg/probodconfig/pg_config.go b/pkg/probodconfig/pg_config.go index c96c35b1f4..319e1d01fb 100644 --- a/pkg/probodconfig/pg_config.go +++ b/pkg/probodconfig/pg_config.go @@ -21,10 +21,15 @@ package probodconfig import ( + "crypto/tls" "crypto/x509" "encoding/pem" + "fmt" + "net" + "strconv" "time" + "github.com/jackc/pgx/v5" "go.gearno.de/kit/pg" ) @@ -128,3 +133,72 @@ func (cfg PgConfig) Options(options ...pg.Option) []pg.Option { return opts } + +func (cfg PgConfig) ConnectionConfig() (*pgx.ConnConfig, error) { + config, err := pgx.ParseConfig("postgres://localhost?sslmode=disable") + if err != nil { + return nil, fmt.Errorf("cannot create base PostgreSQL connection config: %w", err) + } + + host, portValue, err := net.SplitHostPort(cfg.Addr) + if err != nil { + return nil, fmt.Errorf("cannot parse PostgreSQL address: %w", err) + } + + port, err := strconv.ParseUint(portValue, 10, 16) + if err != nil { + return nil, fmt.Errorf("cannot parse PostgreSQL port: %w", err) + } + + config.Host = host + config.Port = uint16(port) + config.User = cfg.Username + config.Password = cfg.Password + config.Database = cfg.Database + + if cfg.CACertBundle != "" { + certificates := parseCertificates(cfg.CACertBundle) + if len(certificates) == 0 { + return nil, fmt.Errorf("PostgreSQL CA certificate bundle has no certificates") + } + + roots := x509.NewCertPool() + for _, certificate := range certificates { + roots.AddCert(certificate) + } + + config.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + ServerName: host, + } + } + + return config, nil +} + +func parseCertificates(bundle string) []*x509.Certificate { + var certificates []*x509.Certificate + + pemData := []byte(bundle) + + for len(pemData) > 0 { + block, remaining := pem.Decode(pemData) + pemData = remaining + + if block == nil { + break + } + + if block.Type != "CERTIFICATE" { + continue + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err == nil { + certificates = append(certificates, certificate) + } + } + + return certificates +} diff --git a/pkg/probodconfig/pg_config_test.go b/pkg/probodconfig/pg_config_test.go new file mode 100644 index 0000000000..988dfe2a21 --- /dev/null +++ b/pkg/probodconfig/pg_config_test.go @@ -0,0 +1,49 @@ +// 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 probodconfig_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/probodconfig" +) + +func TestPgConfig_ConnectionConfig(t *testing.T) { + t.Parallel() + + input := probodconfig.PgConfig{ + Addr: "database.internal:5433", + Username: "probod", + Password: "secret", + Database: "probod_test", + } + config, err := input.ConnectionConfig() + + require.NoError(t, err) + assert.Equal(t, "database.internal", config.Host) + assert.Equal(t, uint16(5433), config.Port) + assert.Equal(t, "probod", config.User) + assert.Equal(t, "secret", config.Password) + assert.Equal(t, "probod_test", config.Database) + assert.Nil(t, config.TLSConfig) +} diff --git a/pkg/realtime/ephemeral.go b/pkg/realtime/ephemeral.go new file mode 100644 index 0000000000..c14a60365a --- /dev/null +++ b/pkg/realtime/ephemeral.go @@ -0,0 +1,111 @@ +// 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 realtime + +import ( + "encoding/json" + "fmt" +) + +// MaxCollaborationEphemeralBytes bounds the encoded NOTIFY payload for an +// ephemeral gossip event. PostgreSQL caps a NOTIFY payload at 8000 bytes; this +// leaves headroom for the JSON envelope and base64 expansion of the frame. +// Presence and cursor frames are far smaller, so an oversized frame is dropped +// from cross-instance gossip (local fan-out still delivers it) rather than +// risking a failed NOTIFY. +const MaxCollaborationEphemeralBytes = 7000 + +// ephemeralKind marks a collaboration-changed NOTIFY payload as an ephemeral +// envelope rather than the bare document-version id that signals a persisted +// change. A bare id is not valid JSON, so the two never collide. +const ephemeralKind = "ephemeral" + +// CollaborationEphemeral is an opaque automerge-repo gossip frame carried across +// server instances over the collaboration NOTIFY channel, so presence and +// cursors reach peers connected to other instances. +type CollaborationEphemeral struct { + // VersionID is the document-version GID string the frame belongs to. + VersionID string + // InstanceID identifies the server instance that published the frame, so the + // publisher can ignore its own echo (it already delivered the frame to its + // local peers directly). + InstanceID string + // Frame is the opaque repo message to relay unchanged. + Frame []byte +} + +type ephemeralEnvelope struct { + Kind string `json:"k"` + VersionID string `json:"v"` + InstanceID string `json:"i"` + Frame []byte `json:"e"` +} + +// EncodeCollaborationEphemeral encodes an ephemeral event into a NOTIFY payload. +// It returns an error when the encoded payload would exceed the NOTIFY size +// budget, so the caller can fall back to local-only fan-out. +func EncodeCollaborationEphemeral(event CollaborationEphemeral) (string, error) { + payload, err := json.Marshal(ephemeralEnvelope{ + Kind: ephemeralKind, + VersionID: event.VersionID, + InstanceID: event.InstanceID, + Frame: event.Frame, + }) + if err != nil { + return "", fmt.Errorf("cannot encode collaboration ephemeral: %w", err) + } + + if len(payload) > MaxCollaborationEphemeralBytes { + return "", fmt.Errorf( + "collaboration ephemeral payload is %d bytes, over the %d-byte limit", + len(payload), MaxCollaborationEphemeralBytes, + ) + } + + return string(payload), nil +} + +// DecodeCollaborationEphemeral decodes a NOTIFY payload as an ephemeral event. It +// reports ok=false for a bare document-version id (the persisted-change signal) +// or any payload that is not a well-formed ephemeral envelope, so callers can +// treat the two payload kinds apart. +func DecodeCollaborationEphemeral(payload string) (CollaborationEphemeral, bool) { + // A bare document-version id is base64url and never starts with '{', so this + // keeps the change-signal path free of a JSON decode. + if len(payload) == 0 || payload[0] != '{' { + return CollaborationEphemeral{}, false + } + + var envelope ephemeralEnvelope + if err := json.Unmarshal([]byte(payload), &envelope); err != nil { + return CollaborationEphemeral{}, false + } + + if envelope.Kind != ephemeralKind || envelope.VersionID == "" { + return CollaborationEphemeral{}, false + } + + return CollaborationEphemeral{ + VersionID: envelope.VersionID, + InstanceID: envelope.InstanceID, + Frame: envelope.Frame, + }, true +} diff --git a/pkg/realtime/ephemeral_test.go b/pkg/realtime/ephemeral_test.go new file mode 100644 index 0000000000..9d0ae771bf --- /dev/null +++ b/pkg/realtime/ephemeral_test.go @@ -0,0 +1,76 @@ +// 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 realtime_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/realtime" +) + +func TestCollaborationEphemeral_RoundTrip(t *testing.T) { + t.Parallel() + + event := realtime.CollaborationEphemeral{ + VersionID: "2AbcDocumentVersionGid", + InstanceID: "instance-7", + Frame: []byte{0x00, 0x01, 0x02, 0xff, 0xfe}, + } + + payload, err := realtime.EncodeCollaborationEphemeral(event) + require.NoError(t, err) + + decoded, ok := realtime.DecodeCollaborationEphemeral(payload) + require.True(t, ok) + assert.Equal(t, event, decoded) +} + +func TestCollaborationEphemeral_BareIDIsNotEphemeral(t *testing.T) { + t.Parallel() + + // A bare document-version id (the persisted-change signal) must not be + // mistaken for an ephemeral envelope. + _, ok := realtime.DecodeCollaborationEphemeral("q4Zx2Yt5gPJpq5RfXAkPfPcUj1rABCD") + assert.False(t, ok) + + _, ok = realtime.DecodeCollaborationEphemeral("") + assert.False(t, ok) + + // Well-formed JSON that is not our envelope is rejected. + _, ok = realtime.DecodeCollaborationEphemeral(`{"hello":"world"}`) + assert.False(t, ok) +} + +func TestCollaborationEphemeral_RejectsOversizedFrame(t *testing.T) { + t.Parallel() + + event := realtime.CollaborationEphemeral{ + VersionID: "version", + InstanceID: "instance", + Frame: []byte(strings.Repeat("A", realtime.MaxCollaborationEphemeralBytes)), + } + + _, err := realtime.EncodeCollaborationEphemeral(event) + assert.Error(t, err) +} diff --git a/pkg/realtime/events.go b/pkg/realtime/events.go new file mode 100644 index 0000000000..fc68d264e9 --- /dev/null +++ b/pkg/realtime/events.go @@ -0,0 +1,68 @@ +// 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 realtime + +import "sync" + +const DocumentCollaborationChannel = "document_collaboration_changed" + +type ( + Handler func(payload string) + + Events struct { + mu sync.RWMutex + handlers map[uint64]Handler + nextHandler uint64 + } +) + +func NewEvents() *Events { + return &Events{handlers: make(map[uint64]Handler)} +} + +func (e *Events) Subscribe(handler Handler) func() { + e.mu.Lock() + id := e.nextHandler + e.nextHandler++ + e.handlers[id] = handler + e.mu.Unlock() + + return func() { + e.mu.Lock() + delete(e.handlers, id) + e.mu.Unlock() + } +} + +func (e *Events) Publish(payload string) { + e.mu.RLock() + + handlers := make([]Handler, 0, len(e.handlers)) + for _, handler := range e.handlers { + handlers = append(handlers, handler) + } + + e.mu.RUnlock() + + for _, handler := range handlers { + handler(payload) + } +} diff --git a/pkg/realtime/events_test.go b/pkg/realtime/events_test.go new file mode 100644 index 0000000000..317060b669 --- /dev/null +++ b/pkg/realtime/events_test.go @@ -0,0 +1,46 @@ +// 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 realtime_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.probo.inc/probo/pkg/realtime" +) + +func TestEvents_SubscribeAndUnsubscribe(t *testing.T) { + t.Parallel() + + events := realtime.NewEvents() + + var received []string + + unsubscribe := events.Subscribe(func(payload string) { + received = append(received, payload) + }) + + events.Publish("first") + unsubscribe() + events.Publish("second") + + assert.Equal(t, []string{"first"}, received) +} diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 12c7406cec..ca103e58d1 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -21,6 +21,7 @@ package api import ( + "context" "errors" "fmt" "net/http" @@ -46,6 +47,7 @@ import ( "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/saferedirect" @@ -64,32 +66,34 @@ import ( type ( Config struct { - BaseURL *baseurl.BaseURL - AllowedOrigins []string - Probo *probo.Service - ResourceAlias *resourcealias.Service - File *filemanager.Service - IAM *iam.Service - Visitor *visitor.Service - ESign *esign.Service - Management *management.Service - CertManager *certmanager.Service - AccessReview *accessreview.Service - AgentRun *agentrun.Service - Slack *slack.Service - Mailman *mailman.Service - CookieBanner *cookiebanner.Service - Geoloc *geoloc.Service - ThirdParty *thirdparty.Service - RiskManagement *riskmanagement.Service - ITAM *itam.Service - Cookie securecookie.Config - TokenSecret string - ConnectorRegistry *connector.ConnectorRegistry - ProviderRegistry *provider.Registry - CustomDomainCname string - GraphQLLimits gqlutils.Limits - Logger *log.Logger + BaseURL *baseurl.BaseURL + AllowedOrigins []string + Probo *probo.Service + CollaborationEvents *realtime.Events + ShutdownContext context.Context + ResourceAlias *resourcealias.Service + File *filemanager.Service + IAM *iam.Service + Visitor *visitor.Service + ESign *esign.Service + Management *management.Service + CertManager *certmanager.Service + AccessReview *accessreview.Service + AgentRun *agentrun.Service + Slack *slack.Service + Mailman *mailman.Service + CookieBanner *cookiebanner.Service + Geoloc *geoloc.Service + ThirdParty *thirdparty.Service + RiskManagement *riskmanagement.Service + ITAM *itam.Service + Cookie securecookie.Config + TokenSecret string + ConnectorRegistry *connector.ConnectorRegistry + ProviderRegistry *provider.Registry + CustomDomainCname string + GraphQLLimits gqlutils.Limits + Logger *log.Logger } MCPConfig struct { @@ -204,6 +208,8 @@ func NewServer(cfg Config) (*Server, error) { consoleHandler: console_v1.NewMux( cfg.Logger.Named("console.v1"), cfg.Probo, + cfg.CollaborationEvents, + cfg.ShutdownContext, cfg.ResourceAlias, cfg.IAM, cfg.ESign, @@ -219,6 +225,7 @@ func NewServer(cfg Config) (*Server, error) { cfg.ProviderRegistry, cfg.File, cfg.BaseURL, + cfg.AllowedOrigins, cfg.CustomDomainCname, cfg.ThirdParty, cfg.RiskManagement, diff --git a/pkg/server/api/console/v1/document_collaboration_handler.go b/pkg/server/api/console/v1/document_collaboration_handler.go new file mode 100644 index 0000000000..aa3b7f00c8 --- /dev/null +++ b/pkg/server/api/console/v1/document_collaboration_handler.go @@ -0,0 +1,239 @@ +// 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 console_v1 + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "time" + + "github.com/coder/websocket" + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/baseurl" + "go.probo.inc/probo/pkg/bearertoken" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/server/api/authn" + "go.probo.inc/probo/pkg/server/jsonx" +) + +// Document collaboration is served over the automerge-repo protocol; see +// document_collaboration_repo_handler.go. This file holds the pieces shared by +// that handler: the handler type, connection authorization, and the small +// WebSocket read/write helpers. +const ( + documentCollaborationMessageMaxBytes = 1024 * 1024 + documentCollaborationWriteTimeout = 10 * time.Second +) + +type ( + documentCollaborationHandler struct { + logger *log.Logger + probo *probo.Service + iam *iam.Service + baseURL *baseurl.BaseURL + allowedOrigins []string + hub *documentCollaborationHub + shutdown context.Context + } + + documentCollaborationIncoming struct { + MessageType websocket.MessageType + Data []byte + Err error + } +) + +func newDocumentCollaborationConnectionID() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", fmt.Errorf("cannot generate document collaboration connection ID: %w", err) + } + + return hex.EncodeToString(value[:]), nil +} + +func (h *documentCollaborationHandler) authorize( + ctx context.Context, + documentVersionID gid.GID, +) (*coredata.Scope, error) { + versionScope, err := h.authorizeResource( + ctx, + documentVersionID, + probo.ActionDocumentVersionGet, + ) + if err != nil { + return nil, fmt.Errorf("cannot authorize document version: %w", err) + } + + version, err := h.probo.Documents.GetVersion(ctx, versionScope, documentVersionID) + if err != nil { + return nil, fmt.Errorf("cannot load authorized document version: %w", err) + } + + scope, err := h.authorizeResource(ctx, version.DocumentID, probo.ActionDocumentUpdate) + if err != nil { + return nil, fmt.Errorf("cannot authorize document update: %w", err) + } + + return scope, nil +} + +func (h *documentCollaborationHandler) authorizeResource( + ctx context.Context, + resourceID gid.GID, + action string, +) (*coredata.Scope, error) { + identity := authn.IdentityFromContext(ctx) + session := authn.SessionFromContext(ctx) + + params := iam.AuthorizeParams{ + Principal: identity.ID, + Resource: resourceID, + Action: action, + ResourceAttributes: make(map[string]string), + } + if session != nil { + params.Session = &session.ID + } + + scope, err := h.iam.Authorizer.Authorize(ctx, params) + if err != nil { + return nil, fmt.Errorf("cannot authorize resource: %w", err) + } + + return scope, nil +} + +func (h *documentCollaborationHandler) renderAuthorizationError(w http.ResponseWriter, err error) { + if scopeErr, ok := errors.AsType[*iam.ErrInsufficientOAuth2Scope](err); ok { + bearertoken.SetBearerInsufficientScope(w, h.baseURL, scopeErr.Scopes...) + jsonx.RenderForbidden(w) + + return + } + + if _, ok := errors.AsType[*iam.ErrInsufficientPermissions](err); ok { + jsonx.RenderForbidden(w) + return + } + + jsonx.RenderNotFound(w, fmt.Errorf("document version not found")) +} + +func (h *documentCollaborationHandler) renderServiceError( + w http.ResponseWriter, + r *http.Request, + documentVersionID string, + err error, +) { + if errors.Is(err, coredata.ErrResourceNotFound) { + jsonx.RenderNotFound(w, fmt.Errorf("document version not found")) + return + } + + if _, ok := errors.AsType[*probo.ErrDocumentVersionNotDraft](err); ok { + httpserver.RenderError(w, http.StatusConflict, err) + return + } + + if _, ok := errors.AsType[*probo.ErrDocumentArchived](err); ok { + httpserver.RenderError(w, http.StatusConflict, err) + return + } + + if _, ok := errors.AsType[*probo.ErrDocumentVersionGenerated](err); ok { + httpserver.RenderError(w, http.StatusConflict, err) + return + } + + h.logger.ErrorCtx( + r.Context(), + "cannot open document collaboration", + log.Error(err), + log.String("document_version_id", documentVersionID), + ) + jsonx.RenderInternalServerError(w) +} + +func (h *documentCollaborationHandler) closeWithError( + ctx context.Context, + connection *websocket.Conn, + documentVersionID string, + err error, +) { + h.logger.ErrorCtx( + ctx, + "document collaboration failed", + log.Error(err), + log.String("document_version_id", documentVersionID), + ) + + _ = connection.Close(websocket.StatusInternalError, "collaboration failed") +} + +func readCollaborationMessages( + ctx context.Context, + connection *websocket.Conn, + incoming chan<- documentCollaborationIncoming, +) { + for { + messageType, data, err := connection.Read(ctx) + + message := documentCollaborationIncoming{ + MessageType: messageType, + Data: data, + Err: err, + } + select { + case incoming <- message: + case <-ctx.Done(): + return + } + + if err != nil { + return + } + } +} + +func writeCollaborationMessage( + ctx context.Context, + connection *websocket.Conn, + messageType websocket.MessageType, + data []byte, +) error { + writeCtx, cancel := context.WithTimeout(ctx, documentCollaborationWriteTimeout) + defer cancel() + + if err := connection.Write(writeCtx, messageType, data); err != nil { + return fmt.Errorf("cannot write collaboration message: %w", err) + } + + return nil +} diff --git a/pkg/server/api/console/v1/document_collaboration_hub.go b/pkg/server/api/console/v1/document_collaboration_hub.go new file mode 100644 index 0000000000..d71a63ec61 --- /dev/null +++ b/pkg/server/api/console/v1/document_collaboration_hub.go @@ -0,0 +1,458 @@ +// 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 console_v1 + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" +) + +type ( + documentCollaborationWake struct { + refresh bool + } + + documentCollaborationRoomPeer struct { + connectionID string + wake chan documentCollaborationWake + // ephemeral carries opaque automerge-repo gossip frames (presence, + // cursors) destined for this peer. It is kept separate from wake so a + // dropped best-effort gossip frame never coalesces with, or masks, a + // sync refresh. Delivery is best-effort: a full buffer drops the + // oldest-style (the frame is skipped), which is acceptable for + // ephemeral state that the sender re-emits. + ephemeral chan []byte + } + + documentCollaborationDocuments interface { + OpenCollaboration( + context.Context, + coredata.Scoper, + gid.GID, + ) (*probo.DocumentCollaboration, error) + PersistCollaboration( + context.Context, + coredata.Scoper, + gid.GID, + *automerge.Document, + ) (int64, error) + } + + documentCollaborationHub struct { + mu sync.Mutex + documents documentCollaborationDocuments + rooms map[gid.GID]*documentCollaborationRoom + instanceID string + } + + documentCollaborationRoom struct { + mu sync.Mutex + collaboration *probo.DocumentCollaboration + documents documentCollaborationDocuments + scope coredata.Scoper + versionID gid.GID + revision atomic.Int64 + peers map[uint64]documentCollaborationRoomPeer + nextPeerID uint64 + dirty chan struct{} + stop chan struct{} + done chan struct{} + persistErr error + } + + documentCollaborationRoomLease struct { + hub *documentCollaborationHub + documentVersionID gid.GID + room *documentCollaborationRoom + peerID uint64 + Wake <-chan documentCollaborationWake + Ephemeral <-chan []byte + seedOwner bool + once sync.Once + } +) + +const documentCollaborationEphemeralBuffer = 64 + +const ( + documentCollaborationPersistDebounce = 50 * time.Millisecond + documentCollaborationPersistTimeout = 5 * time.Second +) + +func newDocumentCollaborationHub( + documents documentCollaborationDocuments, + events *realtime.Events, +) *documentCollaborationHub { + instanceID, err := newDocumentCollaborationConnectionID() + if err != nil { + // A random instance id only suppresses a server's own ephemeral echo; if + // generation ever fails, an empty id degrades to client-side dedup rather + // than breaking the hub. + instanceID = "" + } + + hub := &documentCollaborationHub{ + documents: documents, + rooms: make(map[gid.GID]*documentCollaborationRoom), + instanceID: instanceID, + } + if events != nil { + events.Subscribe(hub.notifyExternal) + } + + return hub +} + +func (h *documentCollaborationHub) acquire( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + connectionID string, +) (*documentCollaborationRoomLease, error) { + h.mu.Lock() + if room := h.rooms[documentVersionID]; room != nil { + lease := h.addPeerLocked(documentVersionID, room, connectionID) + h.mu.Unlock() + + return lease, nil + } + + h.mu.Unlock() + + collaboration, err := h.documents.OpenCollaboration( + ctx, + scope, + documentVersionID, + ) + if err != nil { + return nil, fmt.Errorf("cannot open collaboration room: %w", err) + } + + room := &documentCollaborationRoom{ + collaboration: collaboration, + documents: h.documents, + scope: scope, + versionID: documentVersionID, + peers: make(map[uint64]documentCollaborationRoomPeer), + dirty: make(chan struct{}, 1), + stop: make(chan struct{}), + done: make(chan struct{}), + } + room.revision.Store(collaboration.Revision) + + h.mu.Lock() + if existing := h.rooms[documentVersionID]; existing != nil { + _ = collaboration.Document.Close(context.Background()) + lease := h.addPeerLocked(documentVersionID, existing, connectionID) + h.mu.Unlock() + + return lease, nil + } + + h.rooms[documentVersionID] = room + lease := h.addPeerLocked(documentVersionID, room, connectionID) + lease.seedOwner = collaboration.NeedsSeed + + go room.run() + h.mu.Unlock() + + return lease, nil +} + +func (h *documentCollaborationHub) addPeerLocked( + documentVersionID gid.GID, + room *documentCollaborationRoom, + connectionID string, +) *documentCollaborationRoomLease { + room.mu.Lock() + peerID := room.nextPeerID + room.nextPeerID++ + wake := make(chan documentCollaborationWake, 8) + ephemeral := make(chan []byte, documentCollaborationEphemeralBuffer) + room.peers[peerID] = documentCollaborationRoomPeer{ + connectionID: connectionID, + wake: wake, + ephemeral: ephemeral, + } + room.mu.Unlock() + + return &documentCollaborationRoomLease{ + hub: h, + documentVersionID: documentVersionID, + room: room, + peerID: peerID, + Wake: wake, + Ephemeral: ephemeral, + } +} + +func (l *documentCollaborationRoomLease) Collaboration() *probo.DocumentCollaboration { + return l.room.collaboration +} + +func (l *documentCollaborationRoomLease) Revision() int64 { + return l.room.revision.Load() +} + +func (l *documentCollaborationRoomLease) SeedOwner() bool { + return l.seedOwner +} + +func (l *documentCollaborationRoomLease) SetRevision(revision int64) { + l.room.revision.Store(revision) +} + +func (l *documentCollaborationRoomLease) NotifyPeers() { + l.room.mu.Lock() + defer l.room.mu.Unlock() + + for peerID, peer := range l.room.peers { + if peerID == l.peerID { + continue + } + + select { + case peer.wake <- documentCollaborationWake{}: + default: + } + } +} + +// BroadcastEphemeral fans an opaque automerge-repo gossip frame out to every +// other local peer in the room, leaving the originating peer untouched. Presence +// and cursor state travel as these opaque ephemeral frames. Delivery is +// best-effort; a peer whose buffer is full skips the frame, relying on the +// sender to re-emit its ephemeral state. +// +// This delivers only to peers on the current server instance. Peers on other +// instances are reached separately, by publishing the frame over the +// collaboration NOTIFY channel; the receiving instance delivers it with +// fanoutEphemeral. +func (l *documentCollaborationRoomLease) BroadcastEphemeral(frame []byte) { + l.room.mu.Lock() + defer l.room.mu.Unlock() + + for peerID, peer := range l.room.peers { + if peerID == l.peerID { + continue + } + + select { + case peer.ephemeral <- frame: + default: + } + } +} + +func (h *documentCollaborationHub) notifyExternal(payload string) { + // An ephemeral envelope carries opaque repo gossip (presence, cursors) from + // another instance; a bare document-version id signals a persisted change. + if ephemeral, ok := realtime.DecodeCollaborationEphemeral(payload); ok { + h.notifyExternalEphemeral(ephemeral) + return + } + + documentVersionID, err := gid.ParseGID(payload) + if err != nil || documentVersionID.EntityType() != coredata.DocumentVersionEntityType { + return + } + + h.mu.Lock() + room := h.rooms[documentVersionID] + h.mu.Unlock() + + if room == nil { + return + } + + room.mu.Lock() + defer room.mu.Unlock() + + for _, peer := range room.peers { + select { + case peer.wake <- documentCollaborationWake{refresh: true}: + default: + } + } +} + +func (h *documentCollaborationHub) notifyExternalEphemeral(ephemeral realtime.CollaborationEphemeral) { + // Our own echo: this instance already delivered the frame to its local peers + // when it published it, so re-delivering would surface a peer's own cursor. + if ephemeral.InstanceID != "" && ephemeral.InstanceID == h.instanceID { + return + } + + documentVersionID, err := gid.ParseGID(ephemeral.VersionID) + if err != nil || documentVersionID.EntityType() != coredata.DocumentVersionEntityType { + return + } + + h.mu.Lock() + room := h.rooms[documentVersionID] + h.mu.Unlock() + + if room == nil { + return + } + + room.fanoutEphemeral(ephemeral.Frame) +} + +// fanoutEphemeral delivers a repo gossip frame that arrived from another server +// instance to every local peer in the room. Unlike the lease's BroadcastEphemeral +// there is no originating local peer to exclude. +func (r *documentCollaborationRoom) fanoutEphemeral(frame []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + for _, peer := range r.peers { + select { + case peer.ephemeral <- frame: + default: + } + } +} + +func (l *documentCollaborationRoomLease) SchedulePersist() { + select { + case l.room.dirty <- struct{}{}: + default: + } +} + +func (l *documentCollaborationRoomLease) PersistError() error { + l.room.mu.Lock() + defer l.room.mu.Unlock() + + return l.room.persistErr +} + +func (l *documentCollaborationRoomLease) Close() { + l.once.Do(func() { + l.hub.mu.Lock() + l.room.mu.Lock() + delete(l.room.peers, l.peerID) + + empty := len(l.room.peers) == 0 + + l.room.mu.Unlock() + + if !empty { + l.hub.mu.Unlock() + + return + } + + delete(l.hub.rooms, l.documentVersionID) + l.hub.mu.Unlock() + + if l.room.stop != nil { + close(l.room.stop) + <-l.room.done + } + + _ = l.room.collaboration.Document.Close(context.Background()) + }) +} + +func (r *documentCollaborationRoom) run() { + defer close(r.done) + + var timer *time.Timer + + dirty := false + + for { + var timerChannel <-chan time.Time + if timer != nil { + timerChannel = timer.C + } + + select { + case <-r.dirty: + dirty = true + + if timer == nil { + timer = time.NewTimer(documentCollaborationPersistDebounce) + } else { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + + timer.Reset(documentCollaborationPersistDebounce) + } + case <-timerChannel: + if dirty { + r.persist() + + dirty = false + } + + timer = nil + case <-r.stop: + if timer != nil { + timer.Stop() + } + + if dirty { + r.persist() + } + + return + } + } +} + +func (r *documentCollaborationRoom) persist() { + ctx, cancel := context.WithTimeout( + context.Background(), + documentCollaborationPersistTimeout, + ) + defer cancel() + + revision, err := r.documents.PersistCollaboration( + ctx, + r.scope, + r.versionID, + r.collaboration.Document, + ) + + r.mu.Lock() + defer r.mu.Unlock() + + r.persistErr = err + if err == nil { + r.revision.Store(revision) + } +} diff --git a/pkg/server/api/console/v1/document_collaboration_hub_test.go b/pkg/server/api/console/v1/document_collaboration_hub_test.go new file mode 100644 index 0000000000..21b21d691a --- /dev/null +++ b/pkg/server/api/console/v1/document_collaboration_hub_test.go @@ -0,0 +1,318 @@ +// 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 console_v1 + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" +) + +type fakeDocumentCollaborationDocuments struct { + collaboration *probo.DocumentCollaboration + persisted atomic.Int64 + persistedCh chan struct{} +} + +func (f *fakeDocumentCollaborationDocuments) OpenCollaboration( + context.Context, + coredata.Scoper, + gid.GID, +) (*probo.DocumentCollaboration, error) { + return f.collaboration, nil +} + +func (f *fakeDocumentCollaborationDocuments) PersistCollaboration( + context.Context, + coredata.Scoper, + gid.GID, + *automerge.Document, +) (int64, error) { + revision := f.persisted.Add(1) + 1 + + select { + case f.persistedCh <- struct{}{}: + default: + } + + return revision, nil +} + +func TestDocumentCollaborationRoom_NotifiesOtherPeers(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + document, err := automerge.New(context.Background(), automerge.ActorID{1}) + require.NoError(t, err) + + room := &documentCollaborationRoom{ + collaboration: &probo.DocumentCollaboration{ + Document: document, + Revision: 1, + }, + peers: make(map[uint64]documentCollaborationRoomPeer), + } + room.revision.Store(1) + hub := &documentCollaborationHub{ + rooms: map[gid.GID]*documentCollaborationRoom{ + versionID: room, + }, + } + + hub.mu.Lock() + first := hub.addPeerLocked(versionID, room, "first") + second := hub.addPeerLocked(versionID, room, "second") + hub.mu.Unlock() + + first.SetRevision(2) + first.NotifyPeers() + + assert.Equal(t, int64(2), second.Revision()) + + select { + case <-second.Wake: + default: + require.Fail(t, "second peer was not notified") + } + + select { + case <-first.Wake: + require.Fail(t, "originating peer must not notify itself") + default: + } + + hub.notifyExternal(versionID.String()) + + for _, wakeChannel := range []<-chan documentCollaborationWake{ + first.Wake, + second.Wake, + } { + select { + case wake := <-wakeChannel: + assert.True(t, wake.refresh) + default: + require.Fail(t, "external notification did not wake peer") + } + } + + first.Close() + assert.Contains(t, hub.rooms, versionID) + second.Close() + assert.NotContains(t, hub.rooms, versionID) +} + +func TestDocumentCollaborationRoom_BroadcastsEphemeralToOtherPeers(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + document, err := automerge.New(context.Background(), automerge.ActorID{3}) + require.NoError(t, err) + + room := &documentCollaborationRoom{ + collaboration: &probo.DocumentCollaboration{ + Document: document, + Revision: 1, + }, + peers: make(map[uint64]documentCollaborationRoomPeer), + } + room.revision.Store(1) + hub := &documentCollaborationHub{ + rooms: map[gid.GID]*documentCollaborationRoom{ + versionID: room, + }, + } + + hub.mu.Lock() + first := hub.addPeerLocked(versionID, room, "first") + second := hub.addPeerLocked(versionID, room, "second") + third := hub.addPeerLocked(versionID, room, "third") + hub.mu.Unlock() + + frame := []byte{0x01, 0x02, 0x03} + first.BroadcastEphemeral(frame) + + for _, lease := range []*documentCollaborationRoomLease{second, third} { + select { + case got := <-lease.Ephemeral: + assert.Equal(t, frame, got) + default: + require.Fail(t, "peer did not receive the ephemeral frame") + } + } + + select { + case <-first.Ephemeral: + require.Fail(t, "originating peer must not receive its own ephemeral frame") + default: + } + + require.NoError(t, document.Close(context.Background())) +} + +func TestDocumentCollaborationHub_DeliversExternalEphemeral(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + document, err := automerge.New(context.Background(), automerge.ActorID{5}) + require.NoError(t, err) + + room := &documentCollaborationRoom{ + collaboration: &probo.DocumentCollaboration{Document: document, Revision: 1}, + peers: make(map[uint64]documentCollaborationRoomPeer), + } + room.revision.Store(1) + hub := &documentCollaborationHub{ + rooms: map[gid.GID]*documentCollaborationRoom{versionID: room}, + instanceID: "local-instance", + } + + hub.mu.Lock() + first := hub.addPeerLocked(versionID, room, "first") + second := hub.addPeerLocked(versionID, room, "second") + hub.mu.Unlock() + + frame := []byte{0x0a, 0x0b, 0x0c} + remote, err := realtime.EncodeCollaborationEphemeral(realtime.CollaborationEphemeral{ + VersionID: versionID.String(), + InstanceID: "remote-instance", + Frame: frame, + }) + require.NoError(t, err) + + hub.notifyExternal(remote) + + // A frame from another instance reaches every local peer. + for _, lease := range []*documentCollaborationRoomLease{first, second} { + select { + case got := <-lease.Ephemeral: + assert.Equal(t, frame, got) + default: + require.Fail(t, "peer did not receive the external ephemeral frame") + } + } + + // This instance's own echo is ignored: it already delivered locally. + own, err := realtime.EncodeCollaborationEphemeral(realtime.CollaborationEphemeral{ + VersionID: versionID.String(), + InstanceID: "local-instance", + Frame: []byte{0xff}, + }) + require.NoError(t, err) + + hub.notifyExternal(own) + + select { + case <-first.Ephemeral: + require.Fail(t, "the hub must ignore its own ephemeral echo") + default: + } + + require.NoError(t, document.Close(context.Background())) +} + +func TestDocumentCollaborationRoom_DropsEphemeralWhenBufferFull(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + document, err := automerge.New(context.Background(), automerge.ActorID{4}) + require.NoError(t, err) + + room := &documentCollaborationRoom{ + collaboration: &probo.DocumentCollaboration{Document: document, Revision: 1}, + peers: make(map[uint64]documentCollaborationRoomPeer), + } + room.revision.Store(1) + hub := &documentCollaborationHub{ + rooms: map[gid.GID]*documentCollaborationRoom{versionID: room}, + } + + hub.mu.Lock() + sender := hub.addPeerLocked(versionID, room, "sender") + _ = hub.addPeerLocked(versionID, room, "slow") + hub.mu.Unlock() + + for range documentCollaborationEphemeralBuffer + 10 { + sender.BroadcastEphemeral([]byte{0xff}) + } + + require.NoError(t, document.Close(context.Background())) +} + +func TestDocumentCollaborationRoom_DebouncesPersistence(t *testing.T) { + t.Parallel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + document, err := automerge.New(context.Background(), automerge.ActorID{2}) + require.NoError(t, err) + + documents := &fakeDocumentCollaborationDocuments{ + collaboration: &probo.DocumentCollaboration{ + Document: document, + Revision: 1, + }, + persistedCh: make(chan struct{}, 1), + } + room := &documentCollaborationRoom{ + collaboration: documents.collaboration, + documents: documents, + scope: coredata.NewScope(tenantID), + versionID: versionID, + peers: make(map[uint64]documentCollaborationRoomPeer), + dirty: make(chan struct{}, 1), + stop: make(chan struct{}), + done: make(chan struct{}), + } + + room.revision.Store(1) + go room.run() + + lease := &documentCollaborationRoomLease{room: room} + for range 20 { + lease.SchedulePersist() + } + + select { + case <-documents.persistedCh: + case <-time.After(time.Second): + require.Fail(t, "collaboration room did not persist") + } + + assert.Equal(t, int64(1), documents.persisted.Load()) + assert.Equal(t, int64(2), room.revision.Load()) + + close(room.stop) + <-room.done + require.NoError(t, document.Close(context.Background())) +} diff --git a/pkg/server/api/console/v1/document_collaboration_repo_handler.go b/pkg/server/api/console/v1/document_collaboration_repo_handler.go new file mode 100644 index 0000000000..95b9698c55 --- /dev/null +++ b/pkg/server/api/console/v1/document_collaboration_repo_handler.go @@ -0,0 +1,427 @@ +// 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 console_v1 + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/collaboration" + automergeprosemirror "go.probo.inc/probo/pkg/automerge/prosemirror" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/server/jsonx" +) + +// emptyRichEditorDocument is the ProseMirror document a version with no stored +// content seeds from, matching the frontend's default empty editor. +const emptyRichEditorDocument = `{"type":"doc","content":[{"type":"paragraph"}]}` + +const documentCollaborationRepoRefreshInterval = 500 * time.Millisecond + +// repoCollaborationRefresher pulls changes another server instance persisted +// into the shared in-memory document, so a NOTIFY-driven wake or the periodic +// tick can re-sync connected peers. *probo.DocumentService satisfies it. +type repoCollaborationRefresher interface { + RefreshCollaboration( + ctx context.Context, + scope coredata.Scoper, + documentVersionID gid.GID, + document *automerge.Document, + knownRevision int64, + ) (int64, bool, error) +} + +// repoCollaborationConfig carries the per-connection dependencies of the repo +// collaboration loop. refresher may be nil, which disables cross-instance +// refresh (used by the in-process convergence test that has no database). +type repoCollaborationConfig struct { + serverPeerID string + refresher repoCollaborationRefresher + scope coredata.Scoper + documentVersionID gid.GID + logger *log.Logger + shutdown context.Context + // publishEphemeral relays a gossip frame to other server instances. It may be + // nil, which limits ephemeral fan-out to this instance. + publishEphemeral func(ctx context.Context, frame []byte) error +} + +// handleRepo serves one document version over the automerge-repo protocol. It +// mirrors handle (parse, authorize, acquire a room lease), then drives the +// protocol with the transport-agnostic ServerConn and fans presence/sync out +// through the shared hub. It is mounted alongside the custom /sync route so the +// two protocols coexist during migration; see +// pkg/automerge/collaboration/GATEWAY_CONTRACT.md. +func (h *documentCollaborationHandler) handleRepo(w http.ResponseWriter, r *http.Request) { + documentVersionIDString := chi.URLParam(r, "documentVersionID") + + documentVersionID, err := gid.ParseGID(documentVersionIDString) + if err != nil || documentVersionID.EntityType() != coredata.DocumentVersionEntityType { + jsonx.RenderNotFound(w, fmt.Errorf("document version not found")) + return + } + + scope, err := h.authorize(r.Context(), documentVersionID) + if err != nil { + h.renderAuthorizationError(w, err) + return + } + + connectionID, err := newDocumentCollaborationConnectionID() + if err != nil { + jsonx.RenderInternalServerError(w) + return + } + + lease, err := h.hub.acquire(r.Context(), scope, documentVersionID, connectionID) + if err != nil { + h.renderServiceError(w, r, documentVersionIDString, err) + return + } + + defer lease.Close() + + // The repo protocol has no seed handshake, so the server is authoritative for + // seeding: the connection that claimed the seed converts the version's stored + // ProseMirror content into the CRDT before serving it. The persist that + // follows marks the state seeded, so later connections skip this. + if lease.SeedOwner() { + if err := seedRepoCollaboration(r.Context(), lease); err != nil { + h.renderServiceError(w, r, documentVersionIDString, err) + return + } + } + + connection, err := websocket.Accept( + w, + r, + &websocket.AcceptOptions{ + OriginPatterns: h.allowedOrigins, + CompressionMode: websocket.CompressionDisabled, + }, + ) + if err != nil { + h.logger.WarnCtx( + r.Context(), + "cannot accept document collaboration repo connection", + log.Error(err), + log.String("document_version_id", documentVersionIDString), + ) + + return + } + + defer func() { _ = connection.Close(websocket.StatusNormalClosure, "") }() + + connection.SetReadLimit(documentCollaborationMessageMaxBytes) + + syncState, err := lease.Collaboration().Document.NewSyncState(r.Context()) + if err != nil { + h.closeWithError(r.Context(), connection, documentVersionIDString, err) + return + } + + defer func() { _ = syncState.Close(context.Background()) }() + + config := repoCollaborationConfig{ + serverPeerID: "probo-gateway-" + connectionID, + refresher: h.probo.Documents, + scope: scope, + documentVersionID: documentVersionID, + logger: h.logger, + shutdown: h.shutdown, + publishEphemeral: func(ctx context.Context, frame []byte) error { + return h.probo.Documents.NotifyCollaborationEphemeral( + ctx, + documentVersionID, + h.hub.instanceID, + frame, + ) + }, + } + + if err := serveRepoCollaboration(r.Context(), connection, lease, syncState, config); err != nil { + h.closeWithError(r.Context(), connection, documentVersionIDString, err) + } +} + +// seedRepoCollaboration converts the version's stored ProseMirror content into +// Automerge rich-text spans and writes them into the shared document, then +// schedules a persist. It is called once per document, by the connection that +// claimed the seed, and is safe if the body already exists (it reuses it rather +// than recreating it). +func seedRepoCollaboration(ctx context.Context, lease *documentCollaborationRoomLease) error { + content := lease.Collaboration().SeedContent + if content == "" { + content = emptyRichEditorDocument + } + + spans, err := automergeprosemirror.ToSpans(content) + if err != nil { + return fmt.Errorf("cannot convert seed content to spans: %w", err) + } + + document := lease.Collaboration().Document + + text, err := document.Text(ctx, "body") + if err != nil { + text, err = document.CreateText(ctx, "body") + if err != nil { + return fmt.Errorf("cannot create seed text object: %w", err) + } + } + + if err := text.UpdateSpans(ctx, spans, automergeprosemirror.UpdateSpansConfig()); err != nil { + return fmt.Errorf("cannot write seed spans: %w", err) + } + + if _, err := document.Commit(ctx, "Seed collaboration document", time.Now()); err != nil { + return fmt.Errorf("cannot commit seed: %w", err) + } + + lease.SchedulePersist() + + return nil +} + +// serveRepoCollaboration runs the automerge-repo protocol for one connection. It +// is transport-focused glue over the tested ServerConn driver and the hub's +// fan-out primitives, kept as a standalone function so it can be driven by a +// real ClientConn in tests without a database. It returns nil on a clean close +// and an error only on an unexpected failure. +func serveRepoCollaboration( + ctx context.Context, + connection *websocket.Conn, + lease *documentCollaborationRoomLease, + syncState *automerge.SyncState, + config repoCollaborationConfig, +) error { + conn, err := collaboration.NewAdoptingServerConn( + collaboration.ServerConfig{ServerPeerID: config.serverPeerID}, + syncState, + ) + if err != nil { + return fmt.Errorf("cannot create repo server connection: %w", err) + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + incoming := make(chan documentCollaborationIncoming, 1) + go readCollaborationMessages(ctx, connection, incoming) + + // The first frame is the client's join. + var join documentCollaborationIncoming + select { + case join = <-incoming: + case <-ctx.Done(): + return nil + } + + if join.Err != nil { + return repoReadResult(join.Err) + } + + if join.MessageType != websocket.MessageBinary { + _ = connection.Close(websocket.StatusUnsupportedData, "expected a binary join frame") + return nil + } + + out, accepted, err := conn.Start(ctx, join.Data) + if err != nil { + return fmt.Errorf("cannot start repo server connection: %w", err) + } + + if err := writeRepoFrames(ctx, connection, out); err != nil { + return err + } + + if !accepted { + _ = connection.Close(websocket.StatusPolicyViolation, "unsupported protocol version") + return nil + } + + revision := lease.Revision() + + var tick <-chan time.Time + if config.refresher != nil { + ticker := time.NewTicker(documentCollaborationRepoRefreshInterval) + defer ticker.Stop() + + tick = ticker.C + } + + for { + select { + case <-config.shutdown.Done(): + _ = connection.CloseNow() + return nil + case <-ctx.Done(): + return nil + case message := <-incoming: + if message.Err != nil { + return repoReadResult(message.Err) + } + + // automerge-repo frames are always binary; ignore anything else. + if message.MessageType != websocket.MessageBinary { + continue + } + + reply, fanout, err := conn.Receive(ctx, message.Data) + if err != nil { + return fmt.Errorf("cannot process repo frame: %w", err) + } + + if err := writeRepoFrames(ctx, connection, reply); err != nil { + return err + } + + if fanout != nil { + lease.BroadcastEphemeral(fanout) + + // Relay to peers on other instances. A failure here (including an + // oversized frame) must not drop the connection: local peers + // already have the frame and the sender re-emits its state. + if config.publishEphemeral != nil { + if err := config.publishEphemeral(ctx, fanout); err != nil && config.logger != nil { + config.logger.WarnCtx( + ctx, + "cannot relay collaboration ephemeral across instances", + log.Error(err), + log.String("document_version_id", config.documentVersionID.String()), + ) + } + } + + continue + } + + // A sync frame may have advanced the shared document: wake the other + // peers so they re-sync, and schedule a debounced persist. + lease.NotifyPeers() + + if err := lease.PersistError(); err != nil { + return fmt.Errorf("cannot persist repo collaboration: %w", err) + } + + lease.SchedulePersist() + case frame := <-lease.Ephemeral: + if err := writeCollaborationMessage(ctx, connection, websocket.MessageBinary, frame); err != nil { + return err + } + case wake := <-lease.Wake: + if err := lease.PersistError(); err != nil { + return fmt.Errorf("cannot persist repo collaboration: %w", err) + } + + if wake.refresh && config.refresher != nil { + var changed bool + + revision, changed, err = config.refresher.RefreshCollaboration( + ctx, + config.scope, + config.documentVersionID, + lease.Collaboration().Document, + revision, + ) + if err != nil { + return fmt.Errorf("cannot refresh repo collaboration: %w", err) + } + + lease.SetRevision(revision) + + if !changed { + continue + } + } + + frames, err := conn.SyncChanged(ctx) + if err != nil { + return fmt.Errorf("cannot generate repo sync frames: %w", err) + } + + if err := writeRepoFrames(ctx, connection, frames); err != nil { + return err + } + case <-tick: + if err := lease.PersistError(); err != nil { + return fmt.Errorf("cannot persist repo collaboration: %w", err) + } + + var changed bool + + revision, changed, err = config.refresher.RefreshCollaboration( + ctx, + config.scope, + config.documentVersionID, + lease.Collaboration().Document, + revision, + ) + if err != nil { + return fmt.Errorf("cannot refresh repo collaboration: %w", err) + } + + if !changed { + continue + } + + lease.SetRevision(revision) + + frames, err := conn.SyncChanged(ctx) + if err != nil { + return fmt.Errorf("cannot generate repo sync frames: %w", err) + } + + if err := writeRepoFrames(ctx, connection, frames); err != nil { + return err + } + } + } +} + +// repoReadResult maps a websocket read error to a loop result: a normal or +// going-away close is a clean disconnect (nil), anything else is an error. +func repoReadResult(err error) error { + switch websocket.CloseStatus(err) { + case websocket.StatusNormalClosure, websocket.StatusGoingAway: + return nil + default: + return fmt.Errorf("repo collaboration read failed: %w", err) + } +} + +func writeRepoFrames(ctx context.Context, connection *websocket.Conn, frames [][]byte) error { + for _, frame := range frames { + if err := writeCollaborationMessage(ctx, connection, websocket.MessageBinary, frame); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/server/api/console/v1/document_collaboration_repo_handler_test.go b/pkg/server/api/console/v1/document_collaboration_repo_handler_test.go new file mode 100644 index 0000000000..ffdf7cf13d --- /dev/null +++ b/pkg/server/api/console/v1/document_collaboration_repo_handler_test.go @@ -0,0 +1,463 @@ +// 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 console_v1 + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/automerge" + "go.probo.inc/probo/pkg/automerge/collaboration" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/probo" +) + +// newRepoTestServer stands up an httptest WebSocket server that runs the repo +// collaboration loop against a shared in-memory document, with no database. Each +// connection acquires a lease from the hub, so sync and ephemeral fan-out go +// through the real room machinery. +func newRepoTestServer( + t *testing.T, + hub *documentCollaborationHub, + scope coredata.Scoper, + versionID gid.GID, + publishEphemeral func(ctx context.Context, frame []byte) error, +) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + connectionID, err := newDocumentCollaborationConnectionID() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + lease, err := hub.acquire(r.Context(), scope, versionID, connectionID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + defer lease.Close() + + if lease.SeedOwner() { + if err := seedRepoCollaboration(r.Context(), lease); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + connection, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, // test-only: no Origin from the Go client + }) + if err != nil { + return + } + + defer func() { _ = connection.Close(websocket.StatusNormalClosure, "") }() + + connection.SetReadLimit(1 << 20) + + syncState, err := lease.Collaboration().Document.NewSyncState(r.Context()) + if err != nil { + return + } + + defer func() { _ = syncState.Close(context.Background()) }() + + _ = serveRepoCollaboration(r.Context(), connection, lease, syncState, repoCollaborationConfig{ + serverPeerID: "probo-gateway-" + connectionID, + documentVersionID: versionID, + shutdown: context.Background(), + publishEphemeral: publishEphemeral, + }) + })) + + t.Cleanup(server.Close) + + return server +} + +// repoTestClient drives a real ClientConn over a WebSocket. A single reader +// goroutine owns the connection: it reads server frames, feeds them to the +// driver, and writes the driver's replies plus any ephemerals the test asks to +// send, so there is never a concurrent writer. +type repoTestClient struct { + document *automerge.Document + conn *websocket.Conn + ephemeral chan []byte + send chan []byte + errs chan error +} + +func dialRepoClient( + t *testing.T, + ctx context.Context, + url string, + peerID string, + documentID string, +) *repoTestClient { + t.Helper() + + document, err := automerge.New(ctx, actorFromByte(len(peerID))) + require.NoError(t, err) + + syncState, err := document.NewSyncState(ctx) + require.NoError(t, err) + + driver, err := collaboration.NewClientConn(collaboration.ClientConfig{ + ClientPeerID: peerID, + DocumentID: documentID, + StartsEmpty: true, + }, syncState) + require.NoError(t, err) + + conn, _, err := websocket.Dial(ctx, url, nil) + require.NoError(t, err) + + conn.SetReadLimit(1 << 20) + + client := &repoTestClient{ + document: document, + conn: conn, + ephemeral: make(chan []byte, 8), + send: make(chan []byte, 8), + errs: make(chan error, 1), + } + + frames := make(chan []byte, 8) + go func() { + for { + kind, data, readErr := conn.Read(ctx) + if readErr != nil { + close(frames) + return + } + + if kind != websocket.MessageBinary { + continue + } + + select { + case frames <- data: + case <-ctx.Done(): + return + } + } + }() + + write := func(payload []byte, messageType websocket.MessageType) error { + writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + return conn.Write(writeCtx, messageType, payload) + } + + go func() { + join, startErr := driver.Start() + if startErr != nil { + client.errs <- startErr + return + } + + if err := write(join, websocket.MessageBinary); err != nil { + client.errs <- err + return + } + + var count uint64 + + for { + select { + case <-ctx.Done(): + return + case frame, ok := <-frames: + if !ok { + return + } + + inbound, receiveErr := driver.Receive(ctx, frame) + if receiveErr != nil { + client.errs <- receiveErr + return + } + + for _, outgoing := range inbound.Outgoing { + if err := write(outgoing, websocket.MessageBinary); err != nil { + client.errs <- err + return + } + } + + if inbound.Ephemeral != nil { + select { + case client.ephemeral <- inbound.Ephemeral.Data: + default: + } + } + case payload := <-client.send: + count++ + + frame, ephemeralErr := driver.Ephemeral("session-"+peerID, count, payload) + if ephemeralErr != nil { + client.errs <- ephemeralErr + return + } + + if err := write(frame, websocket.MessageBinary); err != nil { + client.errs <- err + return + } + } + } + }() + + t.Cleanup(func() { _ = conn.Close(websocket.StatusNormalClosure, "") }) + + return client +} + +func actorFromByte(value int) automerge.ActorID { + var actorID automerge.ActorID + actorID[0] = byte(value + 1) + + return actorID +} + +func (c *repoTestClient) waitForBody(t *testing.T, ctx context.Context, want string) { + t.Helper() + + deadline := time.After(10 * time.Second) + for { + select { + case err := <-c.errs: + require.NoError(t, err) + case <-deadline: + require.Failf(t, "client did not converge", "wanted body %q", want) + default: + } + + text, err := c.document.Text(ctx, "body") + if err == nil { + if value, err := text.String(ctx); err == nil && value == want { + return + } + } + + time.Sleep(10 * time.Millisecond) + } +} + +// TestServeRepoCollaboration_ConvergesRealDocument connects a real repo client to +// the gateway loop and confirms it materializes the server's seeded document. +// This exercises the whole server-side stack end to end without a database: +// handshake, document-id adoption, and the sync loop over the hub lease. +func TestServeRepoCollaboration_ConvergesRealDocument(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + + serverDocument, err := automerge.New(ctx, automerge.ActorID{200}) + require.NoError(t, err) + defer func() { _ = serverDocument.Close(ctx) }() + + text, err := serverDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "hello repo")) + _, err = serverDocument.Commit(ctx, "seed", time.Unix(1786147200, 0).UTC()) + require.NoError(t, err) + + documents := &fakeDocumentCollaborationDocuments{ + collaboration: &probo.DocumentCollaboration{Document: serverDocument, Revision: 1}, + } + hub := newDocumentCollaborationHub(documents, nil) + server := newRepoTestServer(t, hub, coredata.NewScope(tenantID), versionID, nil) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + documentID := collaboration.DeriveDocumentID(versionID.String()) + + client := dialRepoClient(t, ctx, wsURL, "client-a", documentID) + client.waitForBody(t, ctx, "hello repo") +} + +// TestServeRepoCollaboration_SeedsUnseededDocument confirms the server seeds an +// unseeded version from its stored ProseMirror content: the connection that +// claims the seed converts the content to spans, and a repo client then +// materializes it. This is the whole server-authoritative seeding path. +func TestServeRepoCollaboration_SeedsUnseededDocument(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + + // A fresh, empty document with no body: exactly what OpenCollaboration + // returns for a version that has never been seeded. + serverDocument, err := automerge.New(ctx, automerge.ActorID{202}) + require.NoError(t, err) + defer func() { _ = serverDocument.Close(ctx) }() + + const seedContent = `{"type":"doc","content":[` + + `{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Seeded"}]},` + + `{"type":"paragraph","content":[{"type":"text","text":"body text"}]}]}` + + documents := &fakeDocumentCollaborationDocuments{ + collaboration: &probo.DocumentCollaboration{ + Document: serverDocument, + Revision: 1, + NeedsSeed: true, + SeedContent: seedContent, + }, + } + hub := newDocumentCollaborationHub(documents, nil) + server := newRepoTestServer(t, hub, coredata.NewScope(tenantID), versionID, nil) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + documentID := collaboration.DeriveDocumentID(versionID.String()) + + client := dialRepoClient(t, ctx, wsURL, "client-a", documentID) + // The flat text of the seeded document is the concatenation of its blocks. + client.waitForBody(t, ctx, "Seededbody text") +} + +// TestServeRepoCollaboration_FansOutEphemeral connects two repo clients and +// confirms an ephemeral one sends is gossiped to the other through the hub, which +// is how repo presence and cursors travel. +func TestServeRepoCollaboration_FansOutEphemeral(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + + serverDocument, err := automerge.New(ctx, automerge.ActorID{201}) + require.NoError(t, err) + defer func() { _ = serverDocument.Close(ctx) }() + + text, err := serverDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "shared")) + _, err = serverDocument.Commit(ctx, "seed", time.Unix(1786147200, 0).UTC()) + require.NoError(t, err) + + documents := &fakeDocumentCollaborationDocuments{ + collaboration: &probo.DocumentCollaboration{Document: serverDocument, Revision: 1}, + } + hub := newDocumentCollaborationHub(documents, nil) + server := newRepoTestServer(t, hub, coredata.NewScope(tenantID), versionID, nil) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + documentID := collaboration.DeriveDocumentID(versionID.String()) + + // Both clients converge first, which guarantees both handshakes completed. + first := dialRepoClient(t, ctx, wsURL, "client-first", documentID) + first.waitForBody(t, ctx, "shared") + + second := dialRepoClient(t, ctx, wsURL, "client-second", documentID) + second.waitForBody(t, ctx, "shared") + + payload := []byte("cursor-at-3") + first.send <- payload + + select { + case got := <-second.ephemeral: + assert.Equal(t, payload, got) + case err := <-second.errs: + require.NoError(t, err) + case <-time.After(10 * time.Second): + require.Fail(t, "the second client did not receive the gossiped ephemeral") + } +} + +// TestServeRepoCollaboration_PublishesEphemeralCrossInstance confirms the loop +// hands each gossiped frame to the cross-instance publisher, which is how +// presence and cursors reach peers on other server instances. +func TestServeRepoCollaboration_PublishesEphemeralCrossInstance(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + tenantID := gid.NewTenantID() + versionID := gid.New(tenantID, coredata.DocumentVersionEntityType) + + serverDocument, err := automerge.New(ctx, automerge.ActorID{203}) + require.NoError(t, err) + defer func() { _ = serverDocument.Close(ctx) }() + + text, err := serverDocument.CreateText(ctx, "body") + require.NoError(t, err) + require.NoError(t, text.Splice(ctx, 0, 0, "shared")) + _, err = serverDocument.Commit(ctx, "seed", time.Unix(1786147200, 0).UTC()) + require.NoError(t, err) + + documents := &fakeDocumentCollaborationDocuments{ + collaboration: &probo.DocumentCollaboration{Document: serverDocument, Revision: 1}, + } + hub := newDocumentCollaborationHub(documents, nil) + + published := make(chan []byte, 4) + publisher := func(_ context.Context, frame []byte) error { + select { + case published <- append([]byte(nil), frame...): + default: + } + + return nil + } + + server := newRepoTestServer(t, hub, coredata.NewScope(tenantID), versionID, publisher) + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + documentID := collaboration.DeriveDocumentID(versionID.String()) + + client := dialRepoClient(t, ctx, wsURL, "client-a", documentID) + client.waitForBody(t, ctx, "shared") + + payload := []byte("cursor-payload") + client.send <- payload + + select { + case frame := <-published: + message, err := collaboration.DecodeMessage(frame) + require.NoError(t, err) + assert.Equal(t, collaboration.MessageEphemeral, message.Type) + assert.Equal(t, payload, message.Data) + case <-time.After(10 * time.Second): + require.Fail(t, "the loop did not publish the ephemeral across instances") + } +} diff --git a/pkg/server/api/console/v1/resolver.go b/pkg/server/api/console/v1/resolver.go index 4217d0d67a..29399f6646 100644 --- a/pkg/server/api/console/v1/resolver.go +++ b/pkg/server/api/console/v1/resolver.go @@ -49,6 +49,7 @@ import ( "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/saferedirect" @@ -91,6 +92,8 @@ type ( func NewMux( logger *log.Logger, proboSvc *probo.Service, + collaborationEvents *realtime.Events, + shutdownContext context.Context, resourceAliasSvc *resourcealias.Service, iamSvc *iam.Service, esignSvc *esign.Service, @@ -106,6 +109,7 @@ func NewMux( providerRegistry *provider.Registry, fileManagerSvc *filemanager.Service, baseURL *baseurl.BaseURL, + allowedOrigins []string, customDomainCname string, thirdPartySvc *thirdparty.Service, riskManagementSvc *riskmanagement.Service, @@ -140,6 +144,23 @@ func NewMux( itamSvc, ) + if shutdownContext == nil { + shutdownContext = context.Background() + } + + collaborationHandler := &documentCollaborationHandler{ + logger: logger, + probo: proboSvc, + iam: iamSvc, + baseURL: baseURL, + allowedOrigins: allowedOrigins, + hub: newDocumentCollaborationHub( + proboSvc.Documents, + collaborationEvents, + ), + shutdown: shutdownContext, + } + r.Group(func(r chi.Router) { r.Use(authn.NewSessionMiddleware(iamSvc, cookieConfig)) r.Use(authn.NewAPIKeyMiddleware(iamSvc, tokenSecret)) @@ -148,6 +169,10 @@ func NewMux( r.Use(dataloader.NewMiddleware(proboSvc, iamSvc, cookieBannerSvc, thirdPartySvc)) r.Handle("/graphql", graphqlHandler) + r.Get( + "/document-versions/{documentVersionID}/repo", + collaborationHandler.handleRepo, + ) r.Get( "/connectors/initiate", diff --git a/pkg/server/server.go b/pkg/server/server.go index fc5d081569..7ddc3ce180 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -21,6 +21,7 @@ package server import ( + "context" "net/http" "github.com/go-chi/chi/v5" @@ -42,6 +43,7 @@ import ( "go.probo.inc/probo/pkg/itam" "go.probo.inc/probo/pkg/mailman" "go.probo.inc/probo/pkg/probo" + "go.probo.inc/probo/pkg/realtime" "go.probo.inc/probo/pkg/resourcealias" "go.probo.inc/probo/pkg/riskmanagement" "go.probo.inc/probo/pkg/securecookie" @@ -56,33 +58,35 @@ import ( ) type Config struct { - BaseURL *baseurl.BaseURL - AllowedOrigins []string - ExtraHeaderFields map[string]string - Probo *probo.Service - ResourceAlias *resourcealias.Service - File *filemanager.Service - IAM *iam.Service - Visitor *visitor.Service - ESign *esign.Service - Management *management.Service - CertManager *certmanager.Service - AccessReview *accessreview.Service - AgentRun *agentrun.Service - Slack *slack.Service - Mailman *mailman.Service - CookieBanner *cookiebanner.Service - Geoloc *geoloc.Service - ThirdParty *thirdparty.Service - RiskManagement *riskmanagement.Service - ITAM *itam.Service - Cookie securecookie.Config - TokenSecret string - ConnectorRegistry *connector.ConnectorRegistry - ProviderRegistry *provider.Registry - CustomDomainCname string - GraphQLLimits gqlutils.Limits - Logger *log.Logger + BaseURL *baseurl.BaseURL + AllowedOrigins []string + ExtraHeaderFields map[string]string + Probo *probo.Service + CollaborationEvents *realtime.Events + ShutdownContext context.Context + ResourceAlias *resourcealias.Service + File *filemanager.Service + IAM *iam.Service + Visitor *visitor.Service + ESign *esign.Service + Management *management.Service + CertManager *certmanager.Service + AccessReview *accessreview.Service + AgentRun *agentrun.Service + Slack *slack.Service + Mailman *mailman.Service + CookieBanner *cookiebanner.Service + Geoloc *geoloc.Service + ThirdParty *thirdparty.Service + RiskManagement *riskmanagement.Service + ITAM *itam.Service + Cookie securecookie.Config + TokenSecret string + ConnectorRegistry *connector.ConnectorRegistry + ProviderRegistry *provider.Registry + CustomDomainCname string + GraphQLLimits gqlutils.Limits + Logger *log.Logger } type Server struct { @@ -100,32 +104,34 @@ type Server struct { func NewServer(cfg Config) (*Server, error) { apiCfg := api.Config{ - BaseURL: cfg.BaseURL, - AllowedOrigins: cfg.AllowedOrigins, - Probo: cfg.Probo, - ResourceAlias: cfg.ResourceAlias, - File: cfg.File, - IAM: cfg.IAM, - Visitor: cfg.Visitor, - ESign: cfg.ESign, - Management: cfg.Management, - CertManager: cfg.CertManager, - AccessReview: cfg.AccessReview, - AgentRun: cfg.AgentRun, - Slack: cfg.Slack, - Mailman: cfg.Mailman, - CookieBanner: cfg.CookieBanner, - Geoloc: cfg.Geoloc, - ThirdParty: cfg.ThirdParty, - RiskManagement: cfg.RiskManagement, - ITAM: cfg.ITAM, - Cookie: cfg.Cookie, - TokenSecret: cfg.TokenSecret, - ConnectorRegistry: cfg.ConnectorRegistry, - ProviderRegistry: cfg.ProviderRegistry, - CustomDomainCname: cfg.CustomDomainCname, - GraphQLLimits: cfg.GraphQLLimits, - Logger: cfg.Logger.Named("api"), + BaseURL: cfg.BaseURL, + AllowedOrigins: cfg.AllowedOrigins, + Probo: cfg.Probo, + CollaborationEvents: cfg.CollaborationEvents, + ShutdownContext: cfg.ShutdownContext, + ResourceAlias: cfg.ResourceAlias, + File: cfg.File, + IAM: cfg.IAM, + Visitor: cfg.Visitor, + ESign: cfg.ESign, + Management: cfg.Management, + CertManager: cfg.CertManager, + AccessReview: cfg.AccessReview, + AgentRun: cfg.AgentRun, + Slack: cfg.Slack, + Mailman: cfg.Mailman, + CookieBanner: cfg.CookieBanner, + Geoloc: cfg.Geoloc, + ThirdParty: cfg.ThirdParty, + RiskManagement: cfg.RiskManagement, + ITAM: cfg.ITAM, + Cookie: cfg.Cookie, + TokenSecret: cfg.TokenSecret, + ConnectorRegistry: cfg.ConnectorRegistry, + ProviderRegistry: cfg.ProviderRegistry, + CustomDomainCname: cfg.CustomDomainCname, + GraphQLLimits: cfg.GraphQLLimits, + Logger: cfg.Logger.Named("api"), } apiServer, err := api.NewServer(apiCfg) diff --git a/turbo.json b/turbo.json index 9bc2016ca0..5b115a5f60 100644 --- a/turbo.json +++ b/turbo.json @@ -13,6 +13,7 @@ "check": { "dependsOn": ["^check"] }, + "test": {}, "dev": { "cache": false, "persistent": true