Compare commits
1 commit
main
...
asch/bette
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b568fd20e |
187 changed files with 4115 additions and 12126 deletions
|
|
@ -1,38 +0,0 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".forgejo/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs
|
||||
path: docs/.vitepress/dist
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: frontend
|
||||
file: frontend/local-client-cli/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
|
||||
|
||||
- name: Build Linux and Windows binaries
|
||||
run: ./scripts/build-sync-server-binaries.sh
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
|
||||
# Create draft release via Forgejo API
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \
|
||||
| jq -r '.id')
|
||||
|
||||
# Upload release assets
|
||||
for file in release/*; do
|
||||
filename=$(basename "$file")
|
||||
curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-F "attachment=@${file}"
|
||||
done
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: sync-server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max
|
||||
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directories: ["/frontend", "/docs"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Disable this for security reasons
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directories: ["**"]
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
|
|
@ -13,7 +13,7 @@ env:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -21,14 +21,15 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
toolchain: "1.89.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Lint & test
|
||||
59
.github/workflows/deploy-docs.yml
vendored
Normal file
59
.github/workflows/deploy-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
|
@ -6,7 +6,7 @@ on:
|
|||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -18,7 +18,7 @@ env:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
|
@ -26,14 +26,15 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
toolchain: "1.89.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Setup rust
|
||||
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-cli
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: frontend
|
||||
file: frontend/local-client-cli/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sign the published Docker image
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
59
.github/workflows/publish-plugin.yml
vendored
Normal file
59
.github/workflows/publish-plugin.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.89.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Build Linux and Windows binaries
|
||||
run: ./scripts/build-sync-server-binaries.sh
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
cd release
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
*
|
||||
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.ref_type == 'tag'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: sync-server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -7,18 +7,15 @@ node_modules
|
|||
# Frontend build folders
|
||||
frontend/*/dist
|
||||
|
||||
sync-server/db.sqlite3*
|
||||
sync-server/databases
|
||||
|
||||
# Rust build folders
|
||||
sync-server/target
|
||||
sync-server/artifacts
|
||||
sync-server/bindings/*.ts
|
||||
|
||||
# build folders
|
||||
sync-server/db.sqlite3*
|
||||
**/databases
|
||||
|
||||
*.log
|
||||
*.sqlx
|
||||
|
||||
target
|
||||
|
||||
.task
|
||||
|
|
|
|||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -5,6 +5,6 @@
|
|||
"**/dist": true,
|
||||
"**/node_modules": true,
|
||||
"**/.sqlx": true,
|
||||
"**/target": true
|
||||
}
|
||||
"**/target": true,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
195
CLAUDE.md
195
CLAUDE.md
|
|
@ -2,154 +2,109 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project shape
|
||||
## Project Overview
|
||||
|
||||
VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo:
|
||||
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
|
||||
|
||||
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
|
||||
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI.
|
||||
## Architecture
|
||||
|
||||
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
|
||||
### Core Components
|
||||
|
||||
### Frontend workspaces
|
||||
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
|
||||
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
|
||||
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
|
||||
- **frontend/test-client/**: CLI testing tool for the sync functionality
|
||||
|
||||
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
|
||||
- `obsidian-plugin` — Obsidian plugin built from `sync-client`.
|
||||
- `local-client-cli` — same engine wrapped as a standalone CLI.
|
||||
- `history-ui` — vault-history web UI.
|
||||
- `test-client` — fuzz E2E harness (random ops across N processes).
|
||||
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
|
||||
### Key Technologies
|
||||
|
||||
## Common commands
|
||||
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
|
||||
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
|
||||
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
|
||||
|
||||
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
|
||||
## Development Commands
|
||||
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
```
|
||||
|
||||
Run the fuzz E2E (N parallel processes):
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh 12
|
||||
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
|
||||
```
|
||||
|
||||
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
|
||||
|
||||
```sh
|
||||
cd sync-server && cargo build --release && cd ..
|
||||
cd frontend
|
||||
npm run build -w sync-client -w deterministic-tests
|
||||
node deterministic-tests/dist/cli.js # all
|
||||
node deterministic-tests/dist/cli.js --filter=rename # subset
|
||||
node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism
|
||||
```
|
||||
|
||||
Run a single sync-client unit test by file:
|
||||
|
||||
```sh
|
||||
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
|
||||
```
|
||||
|
||||
Server: dev runs from `sync-server/` against `config-e2e.yml`:
|
||||
|
||||
```sh
|
||||
### Server Development
|
||||
```bash
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml # dev
|
||||
cargo build --release # used by both e2e harnesses
|
||||
cargo test # unit + ts-rs binding export tests
|
||||
cargo run config-e2e.yml # Start development server
|
||||
cargo test --verbose # Run Rust tests
|
||||
cargo clippy --all-targets --all-features # Lint Rust code
|
||||
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
|
||||
cargo fmt --all -- --check # Check Rust formatting
|
||||
cargo fmt --all # Auto-format Rust code
|
||||
cargo machete --with-metadata # Detect unused dependencies
|
||||
```
|
||||
|
||||
Frontend dev (sync-client + obsidian-plugin watch in parallel):
|
||||
|
||||
```sh
|
||||
cd frontend && npm install && npm run dev
|
||||
### Frontend Development
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
|
||||
npm run build # Build all workspaces
|
||||
npm run test # Run all tests
|
||||
npm run lint # Lint and format TypeScript code
|
||||
```
|
||||
|
||||
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
```
|
||||
|
||||
## SQLite / sqlx
|
||||
|
||||
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
|
||||
|
||||
```sh
|
||||
### Database Setup (Development)
|
||||
```bash
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
cargo sqlx prepare --workspace
|
||||
```
|
||||
|
||||
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
|
||||
|
||||
## Sync engine architecture
|
||||
|
||||
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||
|
||||
The engine is **two independent loops with separate invariants**:
|
||||
|
||||
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
|
||||
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
|
||||
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||
|
||||
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
|
||||
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
|
||||
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
|
||||
|
||||
```ts
|
||||
DocumentRecord = {
|
||||
documentId,
|
||||
parentVersionId,
|
||||
remoteHash?,
|
||||
remoteRelativePath,
|
||||
localPath: RelativePath | undefined
|
||||
}
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Install required cargo tools
|
||||
cargo install sqlx-cli cargo-machete cargo-edit
|
||||
```
|
||||
|
||||
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
|
||||
### Scripts
|
||||
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
|
||||
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
|
||||
- `scripts/e2e.sh`: End-to-end testing
|
||||
- `scripts/clean-up.sh`: Clean logs and database files
|
||||
- `scripts/bump-version.sh patch`: Publish new version
|
||||
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
|
||||
|
||||
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
|
||||
## Code Structure
|
||||
|
||||
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
|
||||
### Workspace Configuration
|
||||
The frontend uses npm workspaces with four packages:
|
||||
- `sync-client`: Core synchronization logic
|
||||
- `obsidian-plugin`: Obsidian-specific integration
|
||||
- `test-client`: Testing utilities
|
||||
- `local-client-cli`: Standalone CLI for VaultLink sync client
|
||||
|
||||
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
|
||||
### Type Generation
|
||||
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
|
||||
|
||||
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
|
||||
### Key Files
|
||||
- `sync-server/src/`: Rust server implementation with WebSocket handlers
|
||||
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
|
||||
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
|
||||
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
|
||||
|
||||
## Edge-case patterns the sync engine has to survive
|
||||
## Testing
|
||||
|
||||
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
|
||||
### Running Tests
|
||||
- Server: `cargo test --verbose`
|
||||
- Frontend: `npm run test` (runs Jest across all workspaces)
|
||||
- E2E: `scripts/e2e.sh`
|
||||
|
||||
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
|
||||
### Test Structure
|
||||
- Rust: Unit tests alongside source files
|
||||
- TypeScript: `.test.ts` files using Jest
|
||||
- E2E: Uses test-client to simulate multiple concurrent users
|
||||
|
||||
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
|
||||
## Code Style
|
||||
|
||||
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
|
||||
### Rust
|
||||
- Uses extensive Clippy lints (see Cargo.toml)
|
||||
- Follows pedantic linting rules
|
||||
- Forbids unsafe code
|
||||
- Uses cargo fmt with default settings
|
||||
|
||||
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
|
||||
|
||||
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
|
||||
|
||||
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
|
||||
|
||||
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
|
||||
|
||||
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
|
||||
|
||||
## Two complementary E2E harnesses
|
||||
|
||||
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
|
||||
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||
|
||||
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
|
||||
|
||||
## Style
|
||||
|
||||
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
|
||||
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
|
||||
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.
|
||||
### TypeScript
|
||||
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
|
||||
- ESLint with unused imports plugin
|
||||
- Consistent across all three frontend packages
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
## Develop
|
||||
|
||||
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
|
||||
### Install [nvm](https://github.com/nvm-sh/nvm)
|
||||
|
||||
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
||||
- `nvm install 25`
|
||||
- `nvm use 25`
|
||||
- Optionally, set the system-wide default: `nvm alias default 25`
|
||||
- `nvm install 22`
|
||||
- `nvm use 22`
|
||||
- Optionally set the system-wide default: `nvm alias default 22`
|
||||
|
||||
### Set up Rust
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@
|
|||
"version": "0.2",
|
||||
"language": "en-GB",
|
||||
"dictionaries": ["en-gb"],
|
||||
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
".vitepress/dist",
|
||||
".vitepress/cache",
|
||||
"package-lock.json"
|
||||
],
|
||||
"words": [
|
||||
"VaultLink",
|
||||
"Obsidian",
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@ This directory contains the VaultLink documentation site built with [VitePress](
|
|||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -9,12 +9,12 @@ Central sync server with multiple clients. High-level architecture and design de
|
|||
│ Clients │
|
||||
├─────────────────────┬───────────────────┬───────────────────┤
|
||||
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
|
||||
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
|
||||
│ (User A - Device1) │ (User A - Device2)│ (Server/Backup) │
|
||||
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ WebSocket
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ WebSocket
|
||||
│ │ │
|
||||
└────────────────────┼────────────────────┘
|
||||
│
|
||||
┌───────────▼───────────┐
|
||||
│ Sync Server │
|
||||
|
|
@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework.
|
|||
|
||||
**Technology**:
|
||||
|
||||
- **Language**: Rust 1.92+
|
||||
- **Language**: Rust 1.89+
|
||||
- **Framework**: Axum (async web framework)
|
||||
- **Database**: SQLite with SQLx
|
||||
- **Protocol**: WebSockets for real-time communication
|
||||
|
|
|
|||
|
|
@ -243,9 +243,9 @@ users:
|
|||
2. Client sends authentication message:
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
}
|
||||
```
|
||||
3. Server validates:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,86 @@
|
|||
|
||||
VaultLink is one of several solutions for synchronising Obsidian vaults. This page compares VaultLink with popular alternatives to help you choose the right tool.
|
||||
|
||||
## Universal sync alternatives
|
||||
|
||||
syncthing
|
||||
|
||||
## VaultLink Obsidian plugin alternatives
|
||||
|
||||
There are already numerious ways to sync an Obsidian vault.
|
||||
|
||||
|
||||
|
||||
- LiveSync - 300k - https://github.com/vrtmrz/obsidian-livesync
|
||||
- Relay - 24k downloads https://github.com/No-Instructions/Relay
|
||||
- Sync server isn't open source
|
||||
- Has to maintain separate CRDT per file, no out-of-band updates
|
||||
- Remotely Save 1.1M downloads https://github.com/remotely-save/remotely-save
|
||||
- conflict resolution is paid pro feature
|
||||
- no tests & trash code
|
||||
- Remotely Sync - 38k downloads https://github.com/sboesen/remotely-sync
|
||||
- Same as remotely save
|
||||
- Self-hosted LiveSync - 300k downloads
|
||||
- SyncFTP 5k - downloads https://github.com/alex-donnan/SyncFTP - ridiculously simple
|
||||
- Syncthing Integration - 22.6k downloads https://github.com/LBF38/obsidian-syncthing-integration
|
||||
- Unfinished
|
||||
- Manual conflict resolution
|
||||
- Git - 1.4M downloads
|
||||
- https://github.com/conql/obsidian-seafile - cool file change indicators
|
||||
|
||||
|
||||
## Obsidian alternatives
|
||||
|
||||
|
||||
- https://anytype.io/
|
||||
- https://github.com/gamosoft/NoteDiscovery?ref=selfh.st
|
||||
- https://github.com/timothepoznanski/poznote?ref=selfh.st
|
||||
- https://appflowy.com/ - could steal editor
|
||||
- https://demo.flatnotes.io/
|
||||
- https://etherpad.org/
|
||||
- https://kitemaker.co/ - discontinued after sale of company
|
||||
- https://b3log.org/siyuan/en/
|
||||
- https://apps.apple.com/us/app/standard-notes/id1285392450
|
||||
- https://affine.pro/
|
||||
- Notion plugin https://developers.notion.com/reference/intro
|
||||
- https://www.xda-developers.com/self-hosted-markdown-editors-that-sync-without-the-cloud/
|
||||
- https://simplenote.com/
|
||||
- https://github.com/fccview/rwMarkable?ref=selfh.st
|
||||
- https://notesnook.com/
|
||||
- https://apps.apple.com/us/app/logseq/id1601013908
|
||||
- blinko
|
||||
- Zen notes https://www.sheshbabu.com/zen/
|
||||
- https://www.getdnote.com/ - CLI only
|
||||
- https://juretriglav.si/open-source-collaborative-text-editors/
|
||||
- https://logseq.com/
|
||||
- https://github.com/suitenumerique/docs - French goverment project
|
||||
- https://silverbullet.md/ - pretty simplistic but nice editor (can't change font though)
|
||||
- https://joplinapp.org/
|
||||
- https://github.com/outline/outline
|
||||
- https://github.com/TriliumNext/Trilium obsidian but worse
|
||||
- https://github.com/suitenumerique/docs
|
||||
- https://livebook.dev/ - collaborative jupyter
|
||||
- https://www.blocknotejs.org/docs/editor-basics for online editor
|
||||
- https://github.com/colanode/colanode - notion like
|
||||
- Blinko
|
||||
- https://www.reddit.com/r/selfhosted/comments/1kswy2n/many_notes_v090_markdown_notetaking_app_designed/#lightbox online editor https://github.com/brufdev/many-notes
|
||||
- https://www.sheshbabu.com/zen/ text editor and database
|
||||
- https://github.com/ekzhang/rustpad
|
||||
- VS Code-eque vibes, single markdown editor based on OT, self-hostable, long
|
||||
- rwmarkable
|
||||
|
||||
Bad ones:
|
||||
- https://cryptpad.org/instances/
|
||||
- https://hedgedoc.org/ - only has split editor
|
||||
- https://www.usememos.com/ - twitter notes
|
||||
- https://alextselegidis.com/get/plainpad/ - too simple
|
||||
- https://standardnotes.com/ - have to pay for folders
|
||||
- https://turtlapp.com/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Key Differentiator: Editor Agnostic
|
||||
|
||||
**VaultLink is not tied to Obsidian.** While it includes an Obsidian plugin for convenience, VaultLink synchronises plain text files and works with any editor:
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
# Getting Started
|
||||
|
||||
Set up VaultLink in 5 minutes. Deploy server, connect clients, done.
|
||||
Set up VaultLink in 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
## Self-hosting the VaultLink server with docker-compose
|
||||
|
||||
- Docker (or Rust toolchain if building from source)
|
||||
- A server (VPS, home server, or localhost for testing)
|
||||
todo: add compose file
|
||||
|
||||
## Step 1: Deploy Server
|
||||
Mount the configuration, databases, and (optionally) logs folder to your host:
|
||||
- Configuration: the default path to it is. Mount the parent folder with something like:
|
||||
```
|
||||
- volumes:
|
||||
|
||||
```
|
||||
and the configuration.yaml will be created on first startup. When a new config option is added to a future server version, the config.yml will be automatically updated. This also means that comments are not preserved inside the file but previously set values are.
|
||||
|
||||
On first startup, an admin account is generated if no configuration.yaml exists or if the existing one contains no users. Changes made to the users section take into effect without requiring a server restart. This is controlled through the `hot-reload` flag within the `users` section.
|
||||
|
||||
- Databases: each vault is stored in its dedicated sqlite database located in the databases folder
|
||||
- Logs: Logs are located at ... and are rotated every 7 days by default.
|
||||
|
||||
Add an expandable examplle <summary/>
|
||||
|
||||
Create `config.yml`:
|
||||
|
||||
|
|
@ -34,7 +46,7 @@ logging:
|
|||
```
|
||||
|
||||
::: tip
|
||||
Generate secure token: `openssl rand -hex 32`
|
||||
You can generate secure token using: `openssl rand -hex 32`
|
||||
:::
|
||||
|
||||
Run server:
|
||||
|
|
@ -46,25 +58,42 @@ docker run -d \
|
|||
-p 3000:3000 \
|
||||
-v $(pwd):/data \
|
||||
ghcr.io/schmelczer/vault-link-server:latest \
|
||||
/app/sync_server /data/config.yml
|
||||
/app/sync_server
|
||||
```
|
||||
|
||||
Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status
|
||||
|
||||
## Step 2: Connect Client
|
||||
Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status and http://localhost:3000 should show a version of this documentation bundled with the server's version.
|
||||
|
||||
### Obsidian Plugin
|
||||
::: warning
|
||||
The server doesn't terminate HTTPS connections and is thus recommended to deploy behind a reverse proxy such as [NGINX] or [Caddy].
|
||||
:::
|
||||
|
||||
::: tip
|
||||
The server uses a local sqlite database to track documents within a Vault as opposed to plain text files. This is necessary so that the full provenance (history) of every document can be retrieved and it also prevents corruption of the server's state coming from outside changes to these files. In case you'd like to keep the latest version of a Vault's files on the server, you can deploy the [CLI client][#cli].
|
||||
:::
|
||||
|
||||
|
||||
|
||||
In case of failures, check the logs on stdout or historical logs in the logs folder.
|
||||
|
||||
[Full server guide →](/guide/obsidian-plugin)
|
||||
|
||||
|
||||
## Connecting to a VaultLink server using the VaultLink Obsidian plugin
|
||||
|
||||
1. Settings → Community Plugins → Browse
|
||||
2. Search "VaultLink", install, enable
|
||||
3. Configure:
|
||||
- Server URL: `ws://localhost:3000` (or `wss://your-server.com` for SSL)
|
||||
3. Then, update the sync settings within the VaultLink options:
|
||||
- Server URL: `http://localhost:3000` (or `https://your-server.com` for SSL)
|
||||
- Token: Your token from config.yml
|
||||
- Vault Name: `default`
|
||||
- Vault Name: Pick any name the token has access to, such as `default`. Users may be allowed to access a predefined list of vaults or every vault managed by the server. See [authorization](#auth) for more on this.
|
||||
|
||||
In case of failures, check the state summary (todo screenshot) or the logs tab available through the follow button from the settings tab (todo screenshot).
|
||||
|
||||
[Full plugin guide →](/guide/obsidian-plugin)
|
||||
|
||||
### CLI Client
|
||||
|
||||
## CLI Client
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
|
|
@ -77,49 +106,4 @@ docker run -d \
|
|||
|
||||
[Full CLI guide →](/guide/cli-client)
|
||||
|
||||
## Production Setup
|
||||
|
||||
For production:
|
||||
|
||||
1. **SSL/TLS**: Use Nginx/Caddy reverse proxy for `wss://` ([setup guide](/guide/server-setup#ssl-tls-with-reverse-proxy))
|
||||
2. **Secure tokens**: Generate with `openssl rand -hex 32`, don't reuse the example
|
||||
3. **Firewall**: Only expose port 3000 to reverse proxy
|
||||
4. **Backups**: SQLite databases are in `databases/` directory
|
||||
|
||||
## Multiple Users
|
||||
|
||||
```yaml
|
||||
users:
|
||||
user_configs:
|
||||
- name: alice
|
||||
token: alice-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- personal
|
||||
- shared
|
||||
- name: bob
|
||||
token: bob-token
|
||||
vault_access:
|
||||
type: allow_list
|
||||
allowed:
|
||||
- shared
|
||||
```
|
||||
|
||||
[Auth docs →](/config/authentication)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Server won't start**: `docker logs vaultlink-server`
|
||||
|
||||
**Client can't connect**:
|
||||
|
||||
1. Verify server: `curl http://your-server:3000/vaults/test/ping`
|
||||
2. Check URL: `ws://` for HTTP, `wss://` for HTTPS
|
||||
3. Verify token matches config.yml
|
||||
|
||||
**Understanding limitations**: [See what VaultLink can and can't do →](/guide/limitations)
|
||||
|
||||
**Files not syncing**: Check client logs, verify vault name matches
|
||||
|
||||
[Server setup →](/guide/server-setup) | [Architecture →](/architecture/)
|
||||
|
|
|
|||
|
|
@ -2,191 +2,28 @@
|
|||
|
||||
VaultLink works well for most Obsidian vaults, but has some constraints you should know about.
|
||||
|
||||
|
||||
## No end-to-end encryption (E2EE)
|
||||
|
||||
VaultLink is meant to be self-hosted, so trust towards the server is expected. This is why E2EE isn't a must-have feature. Additionally, the server needs to be able to read the user's file's content in order to merge it. The [sync algorithm](./sync) doesn't support homomorphic operations and thus must operate on clear text.
|
||||
|
||||
|
||||
## File Type Limitations
|
||||
|
||||
### Mergeable Files
|
||||
|
||||
Only **`.md`** and **`.txt`** files get automatic conflict-free merging.
|
||||
By default, only **`.md`** and **`.txt`** files get automatic conflict-free merging. This can be extended thorugh the `server.mergeable_file_extensions` config to text files with arbitrary file extensions. However, changes to binary files can't be merged automatically. To be fair, they shouldn't be, as without knowing the structure of the binary, the merging algorithm would just corrupt the file.
|
||||
|
||||
Other file types (images, PDFs, etc.) use last-write-wins:
|
||||
So in case of bianry file types (images, PDFs, etc.) use last-write-wins:
|
||||
|
||||
```
|
||||
User A updates diagram.png → Server stores version 1
|
||||
User B updates diagram.png → Server stores version 2 (overwrites A's changes)
|
||||
```
|
||||
|
||||
**Workaround**: Avoid editing the same non-text file simultaneously.
|
||||
|
||||
### Binary Detection
|
||||
|
||||
Files are treated as binary if they:
|
||||
Files are treated as binary if any of the following is true:
|
||||
|
||||
- Their file extension isn't incldued in the `server.mergeable_file_extensions` list (matching is case-insensitive)
|
||||
- Contain NUL bytes (`0x00`)
|
||||
- Fail UTF-8 validation
|
||||
|
||||
Binary files within `.md` or `.txt` extensions still get last-write-wins (no merge).
|
||||
|
||||
## Performance Constraints
|
||||
|
||||
### Server Limits (Configurable)
|
||||
|
||||
| Resource | Default | Maximum Tested |
|
||||
| ------------------------ | ------- | -------------- |
|
||||
| Clients per vault | 256 | ~256 |
|
||||
| Database connections | 12 | 20 |
|
||||
| Max file size | 512 MB | 4096 MB |
|
||||
| Request timeout | 60s | 180s |
|
||||
| WebSocket cursor timeout | 60s | 300s |
|
||||
| Database busy timeout | 3600s | - |
|
||||
|
||||
### Vault Size
|
||||
|
||||
- **Small vaults** (< 1000 files): Excellent performance
|
||||
- **Medium vaults** (1000-10000 files): Good performance
|
||||
- **Large vaults** (> 10000 files): Works, but initial sync slower
|
||||
|
||||
No hard file count limit—constrained by disk space and sync time.
|
||||
|
||||
### Resource Usage
|
||||
|
||||
Rough estimates (varies by vault size and activity):
|
||||
|
||||
- **RAM**: ~50-200 MB base + ~1-5 MB per active client
|
||||
- **CPU**: Low (< 5%) for typical usage, spikes during merges
|
||||
- **Disk**: Vault size + version history (grows over time)
|
||||
|
||||
## Version History
|
||||
|
||||
### Storage
|
||||
|
||||
- All versions stored indefinitely (no automatic cleanup)
|
||||
- Each vault is a separate SQLite database
|
||||
- Deleted files marked as deleted (not purged)
|
||||
|
||||
**Growth**: Version history grows with every change. A 10 MB vault with frequent edits might grow to 100+ MB over months.
|
||||
|
||||
**Cleanup**: Manual only (see [Advanced Configuration](/config/advanced#version-history-cleanup)).
|
||||
|
||||
### Implications
|
||||
|
||||
- Disk usage grows over time
|
||||
- Database size affects backup time
|
||||
- No built-in retention policy
|
||||
|
||||
## Merge Quality
|
||||
|
||||
### Text Merging
|
||||
|
||||
VaultLink uses word-level tokenisation for merging:
|
||||
|
||||
```markdown
|
||||
Parent: "The quick brown fox"
|
||||
User A: "The quick red fox"
|
||||
User B: "The very quick brown fox"
|
||||
Result: "The very quick red fox" ← Both changes preserved
|
||||
```
|
||||
|
||||
**Imperfect scenarios**:
|
||||
|
||||
- Complex nested Markdown (tables, code blocks)
|
||||
- Simultaneous edits to the same sentence
|
||||
- Large structural changes (moving sections around)
|
||||
|
||||
**Result**: Merged file might need manual cleanup in ~1-5% of concurrent edits.
|
||||
|
||||
## Scalability
|
||||
|
||||
### SQLite Limitations
|
||||
|
||||
- One SQLite database per vault
|
||||
- Single-server architecture (no built-in clustering)
|
||||
- Write serialisation through database
|
||||
|
||||
**For high concurrency**: Consider multiple vaults instead of one massive shared vault.
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
Not currently supported. Running multiple servers requires manual vault partitioning.
|
||||
|
||||
## Network Requirements
|
||||
|
||||
### Latency
|
||||
|
||||
- Real-time sync typically < 500ms on good connections
|
||||
- Mobile/slow networks: 1-5s latency possible
|
||||
- Timeout failures on very slow connections (> 60s)
|
||||
|
||||
### Offline Behaviour
|
||||
|
||||
- Clients queue changes locally
|
||||
- On reconnect, sync all changes since last connection
|
||||
- Conflicts resolved automatically (for mergeable files)
|
||||
|
||||
**Limitation**: No offline conflict preview—merged result appears after reconnect.
|
||||
|
||||
## Security
|
||||
|
||||
### No End-to-End Encryption
|
||||
|
||||
- Server sees all file contents
|
||||
- Transport encryption only (WSS/TLS)
|
||||
- Trust your server
|
||||
|
||||
**Workaround**: Self-host on infrastructure you control.
|
||||
|
||||
### Authentication
|
||||
|
||||
- Token-based only (no OAuth, SAML, etc.)
|
||||
- Tokens configured in server config file
|
||||
- No runtime user management
|
||||
|
||||
## Known Edge Cases
|
||||
|
||||
### Simultaneous Deletes and Edits
|
||||
|
||||
```
|
||||
User A deletes note.md
|
||||
User B edits note.md
|
||||
Result: Edit wins (file recreated with B's content)
|
||||
```
|
||||
|
||||
Operational transformation prioritises content preservation.
|
||||
|
||||
### Large File Uploads
|
||||
|
||||
Files > 100 MB may time out on slow connections. Increase `response_timeout_seconds` or split large files.
|
||||
|
||||
### Mobile Sync
|
||||
|
||||
- Mobile networks may drop WebSocket connections frequently
|
||||
- Client auto-reconnects, but causes sync delays
|
||||
- Battery impact from constant reconnections
|
||||
|
||||
## What VaultLink is NOT
|
||||
|
||||
- **Not a backup solution**: Version history helps but isn't a backup (make backups!)
|
||||
- **Not Git**: No branching, no commit messages, no diffs to review before merge
|
||||
- **Not encrypted storage**: Server sees everything
|
||||
- **Not multi-master**: One server, multiple clients (not peer-to-peer)
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Good Use Cases
|
||||
|
||||
- Personal multi-device sync (< 10 devices)
|
||||
- Small team collaboration (< 20 people)
|
||||
- Primarily text/Markdown content
|
||||
- Trusted server environment
|
||||
|
||||
### Poor Use Cases
|
||||
|
||||
- Large teams (> 50 concurrent users per vault)
|
||||
- Primarily binary files (images, videos, large PDFs)
|
||||
- Untrusted server (need E2E encryption)
|
||||
- Highly regulated environments (HIPAA, etc.)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server configuration limits →](/config/server)
|
||||
- [Advanced tuning →](/config/advanced)
|
||||
- [Architecture details →](/architecture/)
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64
|
|||
|
||||
### Build from Source
|
||||
|
||||
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
|
||||
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
|
|
|
|||
|
|
@ -1,71 +0,0 @@
|
|||
# What is VaultLink?
|
||||
|
||||
Self-hosted sync for Obsidian vaults with automatic conflict-free merging. Edit with any tool, collaborate in real-time, no conflict markers.
|
||||
|
||||
## The Problem
|
||||
|
||||
Syncing Obsidian vaults across devices or sharing with teammates sucks:
|
||||
|
||||
- **Commercial services**: Lock-in, subscriptions, third-party access to your data
|
||||
- **Git**: Manual conflict resolution with `<<<<<<<` markers interrupting your workflow
|
||||
- **Cloud storage**: Last-write-wins data loss or manual conflict resolution
|
||||
- **CRDT solutions**: Only work if you edit inside Obsidian (break if you use Vim, VS Code, etc.)
|
||||
|
||||
## VaultLink's Solution
|
||||
|
||||
Differential synchronisation with operational transformation for Markdown and text files.
|
||||
|
||||
Edit `.md` and `.txt` files with Obsidian, Vim, VS Code, or any editor. VaultLink compares versions and automatically merges all changes. No operation tracking required, no conflict markers.
|
||||
|
||||
**Note**: Binary files (images, PDFs, etc.) use last-write-wins. [See limitations →](/guide/limitations)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Server**: Rust WebSocket server with SQLite stores document versions
|
||||
2. **Clients**: Obsidian plugin or CLI client watches filesystem changes
|
||||
3. **Sync**: Changes upload to server, server broadcasts to other clients
|
||||
4. **Merge**: [reconcile-text](https://schmelczer.dev/reconcile) automatically merges concurrent edits
|
||||
|
||||
No CRDT infrastructure. No operation logs. Just file comparison and smart merging.
|
||||
|
||||
## Key Advantages
|
||||
|
||||
**Editor agnostic**: Edit files with any tool. Other solutions break when you edit outside their ecosystem.
|
||||
|
||||
**Self-hosted**: Your data, your server. No third parties, no subscriptions, no surprises.
|
||||
|
||||
**Automatic merging**: Operational transformation handles conflicts without interrupting your workflow.
|
||||
|
||||
**Production-ready**: Comprehensive tests, E2E tests, battle-tested. Many alternatives have zero tests.
|
||||
|
||||
**Collaborative**: Real-time sync with cursor tracking. See where teammates are editing.
|
||||
|
||||
## Not Tied to Obsidian
|
||||
|
||||
VaultLink syncs Markdown files. Use it for:
|
||||
|
||||
- Obsidian vaults (Obsidian desktop + mobile + CLI)
|
||||
- Technical documentation (VS Code, your-editor, CLI)
|
||||
- Academic writing (multiple Markdown editors)
|
||||
- Automated workflows (CLI client for backups/CI/CD)
|
||||
|
||||
The Obsidian plugin is just a convenience wrapper around the sync client.
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| Feature | VaultLink | Git | Cloud Sync | CRDT Solutions |
|
||||
| ------------------- | --------- | --- | ---------- | -------------- |
|
||||
| Self-hosted | ✅ | ✅ | ❌ | Varies |
|
||||
| Any editor | ✅ | ✅ | ✅ | ❌ |
|
||||
| No conflict markers | ✅ | ❌ | ❌ | ✅ |
|
||||
| Real-time | ✅ | ❌ | ❌ | ✅ |
|
||||
| No subscriptions | ✅ | ✅ | ❌ | Varies |
|
||||
| Comprehensive tests | ✅ | N/A | N/A | ❌ |
|
||||
|
||||
[Detailed comparison with alternatives →](/guide/alternatives)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Get started →](/guide/getting-started) (5 minute setup)
|
||||
- [See the architecture →](/architecture/) (understand how it works)
|
||||
- [Compare alternatives →](/guide/alternatives) (why VaultLink vs others)
|
||||
|
|
@ -3,7 +3,7 @@ layout: home
|
|||
|
||||
hero:
|
||||
name: VaultLink
|
||||
text: Self-Hosted Obsidian Sync
|
||||
text: Self-Hosted Sync & Collaboration for Obsidian and beyond
|
||||
tagline: Edit with any tool. Automatic conflict-free merging. Your infrastructure.
|
||||
image:
|
||||
src: /logo.svg
|
||||
|
|
@ -17,29 +17,42 @@ hero:
|
|||
link: /guide/what-is-vaultlink
|
||||
|
||||
features:
|
||||
- title: Edit Anywhere
|
||||
details: Use Obsidian, Vim, VS Code, or any editor. VaultLink syncs files, not keystrokes—edit however you want
|
||||
- title: Single-binary server meant for self-hosting
|
||||
details: Simple Rust-powered WebSocket server with SQLite.
|
||||
|
||||
- title: Your Data, Your Server
|
||||
details: Fully self-hosted. No third parties, no subscriptions, no data mining. Single Docker container or binary
|
||||
- title: No Conflict Markers
|
||||
details: Automatic merge using operational transformation. Never see conflict markers in your notes again
|
||||
details: Built with self-hosting in mind. Single simple Docker container or binary.
|
||||
|
||||
- title: Real-Time Collaboration
|
||||
details: See teammate cursors, merge edits instantly. Rust-powered WebSocket server with SQLite
|
||||
details: See your collaborators' cursors and edits instantly. Or just use it to sync your files across devices.
|
||||
|
||||
- title: Obsidian plugin
|
||||
details: First-class support for Obsidian through the [VaultLink]() plugin. Sync changes coming from both within and outside of your Obsidian Vault.
|
||||
|
||||
- title: Interoperability is the new default
|
||||
details: VaultLink isn't limited to Obsidian. It comes with a client CLI, and can be easily embedded into other editors and knowledgebases as a plugin to unlock true interoperability.
|
||||
|
||||
- title: No Conflict Markers
|
||||
details: Never see conflict markers in your notes again. Automatic smart merging for text without human intervention which never drops changes. See the [reconcile demo](https://schmelczer.dev/reconcile) for an intuitive visualisation.
|
||||
|
||||
- title: Open Source Everything
|
||||
details: MIT licensed. Server, clients, and sync algorithm are all open source. No proprietary components
|
||||
- title: Battle-Tested
|
||||
details: Comprehensive test suite. E2E tests. Used in production. Unlike alternatives with zero tests
|
||||
details: MIT licensed. Server, clients, and sync algorithm are all open source. No proprietary components.
|
||||
---
|
||||
|
||||
## Why Self-Host?
|
||||
## What is VaultLink?
|
||||
|
||||
**You own your knowledge base.** Commercial sync services can disappear, change pricing, or lock you out. VaultLink runs on your infrastructure—VPS, home server, or localhost.
|
||||
VaultLink is an editor agnostic sync & collaboration engine consisting of:
|
||||
- a self-hostable server
|
||||
- an [Obsidian](https://obsidian.md) plugin
|
||||
- and an optional plugin library & CLI client for integrating with other editors
|
||||
|
||||
**Edit with any tool.** Other solutions require CRDT-aware editors or break when you edit outside Obsidian. VaultLink uses differential sync: edit files however you want, sync handles the rest.
|
||||
I'm deeply concived that the shape of a personal knwoledgebase, stack of notes, or "operating system for life" is a folder of Markdown files and attachments. I believe in a rich editing experience built on top of plain text files, owning your own data, and eliminating moats between propriety products.
|
||||
|
||||
**No conflict markers.** Git forces manual merging. Other tools use last-write-wins. VaultLink's operational transformation automatically merges Markdown and text files without conflict markers or workflow interruption. [See what's supported →](/guide/limitations)
|
||||
Obsidian is a well-loved editor ([see the 2025 self-hosting survey]()). Even though it supports E2EE Syncing, it's done through a propriety plugin limited to Obsidian which also lacks support for collaboration. Existing 3rd-party syncing solutions fall short of delivering a stable and editor-agnostic experience. For more on this, see the [alternatives](/guide/alternatives).
|
||||
|
||||
VaultLink finally delivers a reliable, self-hosted, and editor-agnostic syncing solution with first-class support for Obsidian.
|
||||
> And more editors to come in the future, see the [roadmap]().
|
||||
|
||||
[See how VaultLink compares to alternatives →](/guide/alternatives)
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -53,3 +66,10 @@ docker run -d -p 3000:3000 -v $(pwd)/data:/data \
|
|||
Then install the [Obsidian plugin](/guide/obsidian-plugin) or [CLI client](/guide/cli-client).
|
||||
|
||||
[Full setup guide →](/guide/getting-started)
|
||||
|
||||
## What makes VaultLink special
|
||||
|
||||
VaultLink's architecture enables a few unique features:
|
||||
- Its text merging algorithm handles conflicting (even offline & coming from outside the editor) edits without human oversights while ensuring no changes are dropped. Learn more about this in the [syncing algorithm section](architecture/sync-algorithm.md).
|
||||
- Provides its full feature set through shallow integration meaning that it's straightforward to integrate into editors even ones which haven't been written with real-time collaboration and remote backups in mind. This is detailed in the [architecture section](architecture/index.md).
|
||||
- There are various text editor/wiki/knowledgebase style apps & websites around. Users should be able to mix & match between them, try them out, and collaborate with people preferring a different editor than themselves. The long-term goal of VaultLink is to break down these barriers. As of today, the two clients are a CLI exectuable client and an Obsidian plugin. However, there's more to come. So for an overview, head to the [roadmap](roadmap.md) page.
|
||||
|
|
|
|||
5966
docs/package-lock.json
generated
5966
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,118 +0,0 @@
|
|||
# Deterministic Tests
|
||||
|
||||
Scripted multi-client (with an in-memory filesystem) sync tests that run against a real server. Each test defines a sequence of file operations, sync/server controls, and assertions to exercise a specific conflict or edge case.
|
||||
|
||||
Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs through random operations; deterministic tests pin down exact reproduction sequences for known scenarios.
|
||||
|
||||
## How it works
|
||||
|
||||
Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one.
|
||||
|
||||
Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process.
|
||||
|
||||
The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit.
|
||||
|
||||
## Step types
|
||||
|
||||
Clients always start with syncing disabled.
|
||||
|
||||
**File operations** (per-client, fire-and-forget — sync is enqueued but not awaited):
|
||||
|
||||
- `create`, `update`, `rename`, `delete`
|
||||
- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path.
|
||||
|
||||
**Sync control:**
|
||||
|
||||
- `sync` — wait for a specific client or all clients to finish pending operations
|
||||
- `barrier` — retry until all clients converge to identical file state (60s timeout)
|
||||
- `enable-sync` / `disable-sync` — simulate going online/offline
|
||||
- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable
|
||||
- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync`
|
||||
|
||||
**WebSocket control** (per-client):
|
||||
|
||||
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
|
||||
|
||||
**Server control:**
|
||||
|
||||
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process
|
||||
- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire.
|
||||
|
||||
**Fault injection** (per-client):
|
||||
|
||||
- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit.
|
||||
- `wait-for-dropped-create-response` — wait until the armed drop has fired.
|
||||
|
||||
**Assertions:**
|
||||
|
||||
- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Build server first
|
||||
cd sync-server && cargo build --release && cd -
|
||||
|
||||
# Run all tests
|
||||
cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests
|
||||
|
||||
# Filter by name
|
||||
npm run test -w deterministic-tests -- --filter=rename
|
||||
|
||||
# Control parallelism (default: number of CPU cores)
|
||||
npm run test -w deterministic-tests -- -j 4
|
||||
```
|
||||
|
||||
## Adding a test
|
||||
|
||||
1. Create `src/tests/my-scenario.test.ts`:
|
||||
|
||||
```typescript
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const myScenarioTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md offline. After syncing, both clients should have the file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s) => {
|
||||
s.assertFileCount(1).assertContent("A.md", "hello");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
The `verify` callback receives an `AssertableState` object with chainable assertion methods:
|
||||
|
||||
```typescript
|
||||
s.assertFileCount(n); // exact file count
|
||||
s.assertFileExists("path"); // file must exist
|
||||
s.assertFileNotExists("path"); // file must not exist
|
||||
s.assertContent("path", "expected"); // exact content match
|
||||
s.assertContains("path", "a", "b"); // all substrings present in file
|
||||
s.assertContainsAny("path", "a", "b"); // at least one substring present
|
||||
s.assertAnyFileContains("text"); // substring present in some file
|
||||
s.assertNoFileContains("text"); // substring absent from every file
|
||||
s.assertSubstringCount("path", "x", 3); // substring appears exactly N times
|
||||
s.assertContentInAtMostOneFile("text"); // no duplicate content
|
||||
s.ifFileExists("path", (s) => { /* … */ }); // conditional block
|
||||
s.getContent("path"); // raw content (or "" if missing)
|
||||
```
|
||||
|
||||
2. Register it in `src/test-registry.ts`:
|
||||
|
||||
```typescript
|
||||
import { myScenarioTest } from "./tests/my-scenario.test";
|
||||
|
||||
const TESTS = {
|
||||
// ...
|
||||
"my-scenario": myScenarioTest
|
||||
};
|
||||
```
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"name": "deterministic-tests",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"deterministic-tests": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "webpack watch --mode development",
|
||||
"build": "webpack --mode production",
|
||||
"test": "npm run build && node dist/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"@types/node": "^25.0.2",
|
||||
"sync-client": "file:../sync-client",
|
||||
"ts-loader": "^9.5.4",
|
||||
"tslib": "2.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "^5.103.0",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { TestRunner } from "./test-runner";
|
||||
import { ServerControl } from "./server-control";
|
||||
import { ServerManager } from "./server-manager";
|
||||
import { PrefixedLogger } from "./prefixed-logger";
|
||||
import { TESTS } from "./test-registry";
|
||||
import type { TestDefinition, TestResult } from "./test-definition";
|
||||
import { parseArgs } from "./parse-args";
|
||||
import { runWithConcurrency } from "./run-with-concurrency";
|
||||
import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { debugging, Logger } from "sync-client";
|
||||
|
||||
const logger = new Logger();
|
||||
debugging.logToConsole(logger, { useColors: true });
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
logger.error(`Unhandled Rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error(`Uncaught Exception: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const serverManager = new ServerManager(logger);
|
||||
serverManager.installSignalHandlers();
|
||||
|
||||
function testUsesPauseServer(test: TestDefinition): boolean {
|
||||
return test.steps.some(
|
||||
(step) =>
|
||||
step.type === "pause-server" ||
|
||||
step.type === "resume-server" ||
|
||||
step.type === "resume-server-until-history-then-pause"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from the CLI binary's location until we find a directory
|
||||
* containing `sync-server/` and `frontend/`.
|
||||
*/
|
||||
function findProjectRoot(): string {
|
||||
let dir = path.dirname(__filename);
|
||||
const root = path.parse(dir).root;
|
||||
while (dir !== root) {
|
||||
if (
|
||||
fs.existsSync(path.join(dir, "sync-server")) &&
|
||||
fs.existsSync(path.join(dir, "frontend"))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')`
|
||||
);
|
||||
}
|
||||
|
||||
interface NamedTestResult {
|
||||
name: string;
|
||||
result: TestResult;
|
||||
}
|
||||
|
||||
async function runSharedServerTest(
|
||||
name: string,
|
||||
test: TestDefinition,
|
||||
sharedServer: ServerControl
|
||||
): Promise<NamedTestResult> {
|
||||
const testLogger = new PrefixedLogger(logger, name);
|
||||
const runner = new TestRunner(
|
||||
sharedServer,
|
||||
testLogger,
|
||||
TOKEN,
|
||||
sharedServer.remoteUri
|
||||
);
|
||||
const result = await runner.runTest(name, test);
|
||||
if (result.success) {
|
||||
logger.info(`PASSED: ${name} (${result.duration}ms)`);
|
||||
} else {
|
||||
logger.error(`FAILED: ${name} - ${result.error}`);
|
||||
}
|
||||
return { name, result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a test with its own dedicated server (for tests that use pause-server).
|
||||
* SIGSTOP/SIGCONT affects the entire server process, so these tests need
|
||||
* isolated servers to avoid interfering with other tests.
|
||||
*/
|
||||
async function runDedicatedServerTest(
|
||||
name: string,
|
||||
test: TestDefinition,
|
||||
serverPath: string,
|
||||
configPath: string
|
||||
): Promise<NamedTestResult> {
|
||||
const testLogger = new PrefixedLogger(logger, name);
|
||||
const server = new ServerControl(serverPath, configPath, testLogger);
|
||||
serverManager.track(server);
|
||||
|
||||
try {
|
||||
await server.start();
|
||||
const runner = new TestRunner(
|
||||
server,
|
||||
testLogger,
|
||||
TOKEN,
|
||||
server.remoteUri
|
||||
);
|
||||
const result = await runner.runTest(name, test);
|
||||
if (result.success) {
|
||||
logger.info(`PASSED: ${name} (${result.duration}ms)`);
|
||||
} else {
|
||||
logger.error(`FAILED: ${name} - ${result.error}`);
|
||||
}
|
||||
return { name, result };
|
||||
} finally {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
serverManager.untrack(server);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const projectRoot = findProjectRoot();
|
||||
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
logger.error(`Server binary not found at: ${serverPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configPath = path.join(projectRoot, CONFIG_PATH);
|
||||
if (!fs.existsSync(configPath)) {
|
||||
logger.error(`Config file not found at: ${configPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { filter, concurrency } = parseArgs(process.argv);
|
||||
|
||||
const testsToRun: [string, TestDefinition][] = [];
|
||||
for (const [key, test] of Object.entries(TESTS)) {
|
||||
if (test) {
|
||||
if (
|
||||
filter !== undefined &&
|
||||
filter.length > 0 &&
|
||||
!key.includes(filter)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
testsToRun.push([key, test]);
|
||||
}
|
||||
}
|
||||
|
||||
if (testsToRun.length === 0) {
|
||||
logger.error(
|
||||
filter !== undefined && filter.length > 0
|
||||
? `No tests matched filter "${filter}"`
|
||||
: "No tests found"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t));
|
||||
const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t));
|
||||
|
||||
logger.info(`Server: ${serverPath}`);
|
||||
logger.info(`Config: ${configPath}`);
|
||||
logger.info(
|
||||
`Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)`
|
||||
);
|
||||
logger.info(`Concurrency: ${concurrency}`);
|
||||
|
||||
const allResults: NamedTestResult[] = [];
|
||||
|
||||
if (regularTests.length > 0) {
|
||||
logger.info(
|
||||
`\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---`
|
||||
);
|
||||
const sharedServer = new ServerControl(serverPath, configPath, logger);
|
||||
serverManager.track(sharedServer);
|
||||
|
||||
try {
|
||||
await sharedServer.start();
|
||||
|
||||
const results = await runWithConcurrency(
|
||||
regularTests,
|
||||
concurrency,
|
||||
async ([name, test]) =>
|
||||
runSharedServerTest(name, test, sharedServer)
|
||||
);
|
||||
|
||||
allResults.push(...results);
|
||||
} finally {
|
||||
try {
|
||||
await sharedServer.stop();
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Error stopping shared server: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
serverManager.untrack(sharedServer);
|
||||
}
|
||||
}
|
||||
|
||||
if (pauseTests.length > 0) {
|
||||
logger.info(
|
||||
`\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---`
|
||||
);
|
||||
|
||||
const results = await runWithConcurrency(
|
||||
pauseTests,
|
||||
concurrency,
|
||||
async ([name, test]) =>
|
||||
runDedicatedServerTest(name, test, serverPath, configPath)
|
||||
);
|
||||
|
||||
allResults.push(...results);
|
||||
}
|
||||
|
||||
const passed = allResults.filter((r) => r.result.success);
|
||||
const failed = allResults.filter((r) => !r.result.success);
|
||||
|
||||
logger.info(
|
||||
`\n--- Results: ${passed.length}/${allResults.length} passed ---`
|
||||
);
|
||||
|
||||
if (failed.length > 0) {
|
||||
for (const { name, result } of failed) {
|
||||
logger.error(` FAILED: ${name}: ${result.error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
} else {
|
||||
logger.info("All tests passed!");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
logger.error(`Unexpected error: ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
export const TOKEN = "test-token-change-me";
|
||||
export const SERVER_BINARY_PATH = "sync-server/target/release/sync_server";
|
||||
export const CONFIG_PATH = "sync-server/config-e2e.yml";
|
||||
|
||||
export const STOP_TIMEOUT_MS = 5_000;
|
||||
export const CONVERGENCE_TIMEOUT_MS = 60_000;
|
||||
export const CONVERGENCE_RETRY_DELAY_MS = 500;
|
||||
export const AGENT_INIT_TIMEOUT_MS = 30_000;
|
||||
export const IS_SYNC_ENABLED_BY_DEFAULT = false;
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 60_000;
|
||||
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
|
||||
export const WEBSOCKET_POLL_INTERVAL_MS = 50;
|
||||
|
||||
export const SERVER_READY_POLL_INTERVAL_MS = 100;
|
||||
export const SERVER_READY_MAX_ATTEMPTS = 50;
|
||||
export const SERVER_START_MAX_ATTEMPTS = 5;
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
import type {
|
||||
HistoryEntry,
|
||||
StoredDatabase,
|
||||
SyncSettings,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import {
|
||||
SyncClient,
|
||||
SyncResetError,
|
||||
debugging,
|
||||
LogLevel,
|
||||
utils
|
||||
} from "sync-client";
|
||||
import { assert } from "./utils/assert";
|
||||
import { sleep } from "./utils/sleep";
|
||||
import { withTimeout } from "./utils/with-timeout";
|
||||
import {
|
||||
IS_SYNC_ENABLED_BY_DEFAULT,
|
||||
WAIT_TIMEOUT_MS,
|
||||
WEBSOCKET_CONNECT_TIMEOUT_MS,
|
||||
WEBSOCKET_POLL_INTERVAL_MS
|
||||
} from "./consts";
|
||||
import { ManagedWebSocketFactory } from "./managed-websocket";
|
||||
|
||||
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||
public readonly clientId: number;
|
||||
private readonly logger: (msg: string) => void;
|
||||
private client!: SyncClient;
|
||||
private data: Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
}> = {};
|
||||
private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT;
|
||||
private readonly syncErrors: Error[] = [];
|
||||
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
||||
private readonly wsFactory = new ManagedWebSocketFactory();
|
||||
private nextWriteRename:
|
||||
| {
|
||||
oldPath: RelativePath;
|
||||
newPath: RelativePath;
|
||||
}
|
||||
| undefined;
|
||||
private nextCreateResponseDrop:
|
||||
| {
|
||||
dropped: Promise<void>;
|
||||
resolveDropped: () => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
public constructor(
|
||||
clientId: number,
|
||||
initialSettings: Partial<SyncSettings>,
|
||||
logger: (msg: string) => void
|
||||
) {
|
||||
super();
|
||||
this.clientId = clientId;
|
||||
this.logger = logger;
|
||||
this.data.settings = { ...initialSettings };
|
||||
}
|
||||
|
||||
public async init(
|
||||
fetchImplementation: typeof globalThis.fetch
|
||||
): Promise<void> {
|
||||
this.client = await SyncClient.create({
|
||||
fs: this,
|
||||
persistence: {
|
||||
load: async () => this.data,
|
||||
save: async (data) => void (this.data = data)
|
||||
},
|
||||
fetch: this.wrapFetch(fetchImplementation),
|
||||
webSocket: this.wsFactory.constructorFn
|
||||
});
|
||||
|
||||
this.client.logger.onLogEmitted.add((line) => {
|
||||
const prefix = `[Client ${this.clientId}]`;
|
||||
switch (line.level) {
|
||||
case LogLevel.ERROR:
|
||||
this.logger(`${prefix} ERROR: ${line.message}`);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
this.logger(`${prefix} WARN: ${line.message}`);
|
||||
break;
|
||||
case LogLevel.INFO:
|
||||
this.logger(`${prefix} INFO: ${line.message}`);
|
||||
break;
|
||||
case LogLevel.DEBUG:
|
||||
this.logger(`${prefix} DEBUG: ${line.message}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
await this.client.start();
|
||||
|
||||
const connectionCheck = await this.client.checkConnection();
|
||||
assert(
|
||||
connectionCheck.isSuccessful,
|
||||
`Client ${this.clientId} connection check failed`
|
||||
);
|
||||
|
||||
if (this.isSyncEnabled) {
|
||||
await this.waitForWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
public pauseWebSocket(): void {
|
||||
this.log("Pausing WebSocket message delivery");
|
||||
this.wsFactory.pause();
|
||||
}
|
||||
|
||||
public resumeWebSocket(): void {
|
||||
this.log("Resuming WebSocket message delivery");
|
||||
this.wsFactory.resume();
|
||||
}
|
||||
|
||||
public dropNextCreateResponse(): void {
|
||||
assert(
|
||||
this.nextCreateResponseDrop === undefined,
|
||||
`Client ${this.clientId} already has a create response drop armed`
|
||||
);
|
||||
let resolveDropped!: () => void;
|
||||
const dropped = new Promise<void>((resolve) => {
|
||||
resolveDropped = resolve;
|
||||
});
|
||||
this.nextCreateResponseDrop = {
|
||||
dropped,
|
||||
resolveDropped
|
||||
};
|
||||
this.log("Armed next create response drop");
|
||||
}
|
||||
|
||||
public async waitForDroppedCreateResponse(): Promise<void> {
|
||||
assert(
|
||||
this.nextCreateResponseDrop !== undefined,
|
||||
`Client ${this.clientId} has no create response drop armed`
|
||||
);
|
||||
await withTimeout(
|
||||
this.nextCreateResponseDrop.dropped,
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} timed out waiting for create response drop`
|
||||
);
|
||||
this.log("Create response was dropped after server commit");
|
||||
}
|
||||
|
||||
public async waitForHistoryEntry(
|
||||
matches: (entry: HistoryEntry) => boolean,
|
||||
onMatch?: (entry: HistoryEntry) => void
|
||||
): Promise<void> {
|
||||
const existing = this.client.getHistoryEntries().find(matches);
|
||||
if (existing !== undefined) {
|
||||
onMatch?.(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve) => {
|
||||
const unsubscribe = this.client.onSyncHistoryUpdated.add(() => {
|
||||
const entry = this.client
|
||||
.getHistoryEntries()
|
||||
.find(matches);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe();
|
||||
onMatch?.(entry);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} timed out waiting for history entry`
|
||||
);
|
||||
}
|
||||
|
||||
public async waitForSync(): Promise<void> {
|
||||
this.log("Waiting for sync to complete...");
|
||||
// Drain agent-level sync operations first. These are the fire-and-forget
|
||||
// promises from enqueueSync() that call into the SyncClient's methods.
|
||||
// Without this, waitUntilFinished() might return before the SyncClient
|
||||
// has even been told about the operation.
|
||||
await this.drainPendingSyncOperations();
|
||||
await withTimeout(
|
||||
this.client.waitUntilFinished(),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms`
|
||||
);
|
||||
if (this.syncErrors.length > 0) {
|
||||
const errors = this.syncErrors.splice(0);
|
||||
throw new Error(
|
||||
`Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}`
|
||||
);
|
||||
}
|
||||
this.log("Sync complete");
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
this.log("Resetting client (clears tracked state, keeps disk files)");
|
||||
await this.drainPendingSyncOperations();
|
||||
await this.client.reset();
|
||||
if (this.isSyncEnabled) {
|
||||
await this.waitForWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
public async disableSync(): Promise<void> {
|
||||
this.log("Disabling sync");
|
||||
// Drain pending enqueued operations before disabling so the SyncClient
|
||||
// knows about all operations that were enqueued while sync was enabled.
|
||||
await this.drainPendingSyncOperations();
|
||||
await this.client.setSetting("isSyncEnabled", false);
|
||||
this.isSyncEnabled = false;
|
||||
// Wait for in-flight operations to drain. Disabling sync triggers
|
||||
// a reset, which aborts in-flight fetches with SyncResetError.
|
||||
try {
|
||||
await withTimeout(
|
||||
this.client.waitUntilFinished(),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} disableSync drain timed out`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SyncResetError") {
|
||||
this.log("Disable sync drain interrupted by reset (expected)");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async enableSync(): Promise<void> {
|
||||
this.log("Enabling sync");
|
||||
await this.client.setSetting("isSyncEnabled", true);
|
||||
this.isSyncEnabled = true;
|
||||
await this.waitForWebSocket();
|
||||
}
|
||||
|
||||
public async getFileContent(path: string): Promise<string> {
|
||||
const bytes = await this.read(path);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
public renameNextWrite(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
assert(
|
||||
this.nextWriteRename === undefined,
|
||||
`Client ${this.clientId} already has a next-write rename armed`
|
||||
);
|
||||
this.nextWriteRename = { oldPath, newPath };
|
||||
this.log(`Armed next write rename: ${oldPath} -> ${newPath}`);
|
||||
}
|
||||
|
||||
public async cleanup(): Promise<void> {
|
||||
this.log("Cleaning up...");
|
||||
// Guard against uninitialized client (init() failed partway).
|
||||
// The class field uses `!:` so TS thinks this is always defined,
|
||||
// but at runtime it can be undefined when init() throws partway.
|
||||
const maybeClient = this.client as SyncClient | undefined;
|
||||
if (maybeClient === undefined) {
|
||||
this.log("Client not initialized, nothing to clean up");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.drainPendingSyncOperations();
|
||||
await withTimeout(
|
||||
this.client.waitUntilFinished(),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} cleanup waitUntilFinished timed out`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SyncResetError") {
|
||||
this.log(`Cleanup interrupted by reset (expected): ${error}`);
|
||||
} else {
|
||||
this.log(`Cleanup waitUntilFinished failed: ${error}`);
|
||||
}
|
||||
}
|
||||
// Surface any background sync errors that arrived after the last
|
||||
// waitForSync (e.g. between the final assert-consistent and here).
|
||||
// Without this, regressions that fault the engine during the very
|
||||
// last step of a test would be silently swallowed.
|
||||
const pendingErrors = this.syncErrors.splice(0);
|
||||
await this.client.destroy();
|
||||
this.log("Cleanup complete");
|
||||
if (pendingErrors.length > 0) {
|
||||
throw new Error(
|
||||
`Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public override async read(path: RelativePath): Promise<Uint8Array> {
|
||||
await Promise.resolve();
|
||||
return super.read(path);
|
||||
}
|
||||
|
||||
public override async write(
|
||||
path: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
await Promise.resolve();
|
||||
const isNew = !this.files.has(path);
|
||||
await super.write(path, content);
|
||||
|
||||
if (this.isSyncEnabled && isNew) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyCreatedFile(path);
|
||||
});
|
||||
}
|
||||
|
||||
const nextWriteRename = this.nextWriteRename;
|
||||
if (
|
||||
nextWriteRename !== undefined &&
|
||||
nextWriteRename.oldPath === path
|
||||
) {
|
||||
this.nextWriteRename = undefined;
|
||||
await super.rename(
|
||||
nextWriteRename.oldPath,
|
||||
nextWriteRename.newPath
|
||||
);
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath: nextWriteRename.oldPath,
|
||||
relativePath: nextWriteRename.newPath
|
||||
});
|
||||
});
|
||||
}
|
||||
// The rename consumed `path`. Skip the post-update enqueue below
|
||||
// — it would send a syncLocallyUpdatedFile for a path that no
|
||||
// longer exists.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
const result = await super.atomicUpdateText(path, updater);
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public override async delete(path: RelativePath): Promise<void> {
|
||||
await super.delete(path);
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyDeletedFile(path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
await super.rename(oldPath, newPath);
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () => {
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForWebSocket(): Promise<void> {
|
||||
const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS;
|
||||
while (!this.client.isWebSocketConnected && Date.now() < deadline) {
|
||||
await sleep(WEBSOCKET_POLL_INTERVAL_MS);
|
||||
}
|
||||
assert(
|
||||
this.client.isWebSocketConnected,
|
||||
`Client ${this.clientId} WebSocket failed to connect within ${WEBSOCKET_CONNECT_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until all agent-level enqueued sync operations have completed.
|
||||
* Uses a loop because completing one operation can trigger new enqueues.
|
||||
*/
|
||||
private async drainPendingSyncOperations(): Promise<void> {
|
||||
while (this.pendingSyncOperations.size > 0) {
|
||||
await utils.awaitAll([...this.pendingSyncOperations]);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueSync(operation: () => Promise<void>): void {
|
||||
const promise = this.executeSyncOperation(operation).catch(
|
||||
(error: unknown) => {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.log(`Background sync failed: ${err.message}`);
|
||||
this.syncErrors.push(err);
|
||||
}
|
||||
);
|
||||
this.pendingSyncOperations.add(promise);
|
||||
void promise.finally(() => {
|
||||
this.pendingSyncOperations.delete(promise);
|
||||
});
|
||||
}
|
||||
|
||||
private async executeSyncOperation(
|
||||
operation: () => Promise<void>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SyncResetError") {
|
||||
this.log(`Sync operation interrupted by reset: ${error}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message.includes("has been destroyed")
|
||||
) {
|
||||
this.log(`Sync operation interrupted by destroy: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||
}
|
||||
|
||||
private wrapFetch(
|
||||
fetchImplementation: typeof globalThis.fetch
|
||||
): typeof globalThis.fetch {
|
||||
return async (input, init) => {
|
||||
const response = await fetchImplementation(input, init);
|
||||
const drop = this.nextCreateResponseDrop;
|
||||
if (
|
||||
drop !== undefined &&
|
||||
DeterministicAgent.isCreateDocumentRequest(input, init)
|
||||
) {
|
||||
this.nextCreateResponseDrop = undefined;
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
// Best-effort — body may already be consumed/closed.
|
||||
}
|
||||
drop.resolveDropped();
|
||||
throw new SyncResetError();
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
private static isCreateDocumentRequest(
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined
|
||||
): boolean {
|
||||
const method =
|
||||
init?.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request
|
||||
? input.method
|
||||
: "GET");
|
||||
if (method.toUpperCase() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url =
|
||||
input instanceof URL
|
||||
? input
|
||||
: new URL(typeof input === "string" ? input : input.url);
|
||||
return /\/documents\/?$/.test(url.pathname);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
/**
|
||||
* A WebSocket wrapper that can pause and resume message delivery.
|
||||
* When paused, incoming messages are buffered. When resumed, buffered
|
||||
* messages are delivered in order via the onmessage handler.
|
||||
*
|
||||
* Member layout follows typescript-eslint default member-ordering: all
|
||||
* accessor properties are declared with `declare` and wired through the
|
||||
* constructor using Object.defineProperty so we don't need conflicting
|
||||
* get/set accessor pairs.
|
||||
*/
|
||||
class ManagedWebSocket implements WebSocket {
|
||||
public static readonly CONNECTING = WebSocket.CONNECTING;
|
||||
public static readonly OPEN = WebSocket.OPEN;
|
||||
public static readonly CLOSING = WebSocket.CLOSING;
|
||||
public static readonly CLOSED = WebSocket.CLOSED;
|
||||
|
||||
public readonly CONNECTING = WebSocket.CONNECTING;
|
||||
public readonly OPEN = WebSocket.OPEN;
|
||||
public readonly CLOSING = WebSocket.CLOSING;
|
||||
public readonly CLOSED = WebSocket.CLOSED;
|
||||
|
||||
declare public readonly readyState: number;
|
||||
declare public readonly url: string;
|
||||
declare public readonly protocol: string;
|
||||
declare public readonly extensions: string;
|
||||
declare public readonly bufferedAmount: number;
|
||||
declare public binaryType: BinaryType;
|
||||
declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
declare public onclose:
|
||||
| ((this: WebSocket, ev: CloseEvent) => unknown)
|
||||
| null;
|
||||
declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null;
|
||||
declare public onmessage:
|
||||
| ((this: WebSocket, ev: MessageEvent) => unknown)
|
||||
| null;
|
||||
|
||||
private readonly ws: WebSocket;
|
||||
private readonly bufferedMessages: MessageEvent[] = [];
|
||||
private paused = false;
|
||||
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
|
||||
|
||||
public constructor(url: string | URL, protocols?: string | string[]) {
|
||||
this.ws = new WebSocket(url, protocols);
|
||||
|
||||
const { ws } = this;
|
||||
Object.defineProperties(this, {
|
||||
readyState: {
|
||||
get: (): number => ws.readyState,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
url: {
|
||||
get: (): string => ws.url,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
protocol: {
|
||||
get: (): string => ws.protocol,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
extensions: {
|
||||
get: (): string => ws.extensions,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
bufferedAmount: {
|
||||
get: (): number => ws.bufferedAmount,
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
binaryType: {
|
||||
get: (): BinaryType => ws.binaryType,
|
||||
set: (v: BinaryType): void => {
|
||||
ws.binaryType = v;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
onopen: {
|
||||
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
|
||||
ws.onopen,
|
||||
set: (
|
||||
h: ((this: WebSocket, ev: Event) => unknown) | null
|
||||
): void => {
|
||||
ws.onopen = h;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
onclose: {
|
||||
get: ():
|
||||
| ((this: WebSocket, ev: CloseEvent) => unknown)
|
||||
| null => ws.onclose,
|
||||
set: (
|
||||
h: ((this: WebSocket, ev: CloseEvent) => unknown) | null
|
||||
): void => {
|
||||
ws.onclose = h;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
onerror: {
|
||||
get: (): ((this: WebSocket, ev: Event) => unknown) | null =>
|
||||
ws.onerror,
|
||||
set: (
|
||||
h: ((this: WebSocket, ev: Event) => unknown) | null
|
||||
): void => {
|
||||
ws.onerror = h;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
},
|
||||
onmessage: {
|
||||
get: ():
|
||||
| ((this: WebSocket, ev: MessageEvent) => unknown)
|
||||
| null => this.externalOnMessage,
|
||||
set: (
|
||||
h: ((this: WebSocket, ev: MessageEvent) => unknown) | null
|
||||
): void => {
|
||||
this.externalOnMessage = h;
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.onmessage = (event: MessageEvent): void => {
|
||||
if (this.paused) {
|
||||
this.bufferedMessages.push(event);
|
||||
} else {
|
||||
this.externalOnMessage?.(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.paused = true;
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
// Drain buffered messages BEFORE flipping `paused` to false.
|
||||
// If `externalOnMessage` is async (its return type is `unknown`),
|
||||
// dispatch yields control between buffered messages, and a fresh
|
||||
// live `ws.onmessage` event firing during that yield would jump
|
||||
// ahead of unprocessed buffered messages — silently reordering
|
||||
// events relative to the wire. Keeping `paused = true` during the
|
||||
// drain forces the live handler to keep buffering, so we splice
|
||||
// those late arrivals onto the tail and dispatch them in order.
|
||||
while (this.bufferedMessages.length > 0) {
|
||||
const messages = this.bufferedMessages.splice(0);
|
||||
for (const msg of messages) {
|
||||
this.externalOnMessage?.(msg);
|
||||
}
|
||||
}
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
||||
this.ws.send(data);
|
||||
}
|
||||
|
||||
public close(code?: number, reason?: string): void {
|
||||
this.ws.close(code, reason);
|
||||
}
|
||||
|
||||
public addEventListener(
|
||||
...args: Parameters<WebSocket["addEventListener"]>
|
||||
): void {
|
||||
// Only the `.onmessage` setter routes through the pause buffer.
|
||||
// If sync-client ever attaches "message" listeners via
|
||||
// addEventListener instead, those messages would bypass pause/resume
|
||||
// and deterministic tests would silently lose their fault injection.
|
||||
if (args[0] === "message") {
|
||||
throw new Error(
|
||||
"ManagedWebSocket: addEventListener('message') bypasses the " +
|
||||
"pause buffer. Use the .onmessage setter instead, or " +
|
||||
"extend ManagedWebSocket to route message listeners."
|
||||
);
|
||||
}
|
||||
this.ws.addEventListener(...args);
|
||||
}
|
||||
|
||||
public removeEventListener(
|
||||
...args: Parameters<WebSocket["removeEventListener"]>
|
||||
): void {
|
||||
this.ws.removeEventListener(...args);
|
||||
}
|
||||
|
||||
public dispatchEvent(event: Event): boolean {
|
||||
return this.ws.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that creates ManagedWebSocket instances and tracks them
|
||||
* for pause/resume control from the test harness
|
||||
*/
|
||||
export class ManagedWebSocketFactory {
|
||||
// Append-only: closed sockets stay tracked. Bounded per test (one
|
||||
// factory per agent, each test discards its agents on cleanup), so
|
||||
// not a real leak — but iterating over closed instances on
|
||||
// pause/resume is a deliberate no-op since their `.onmessage` is
|
||||
// already detached.
|
||||
private readonly instances: ManagedWebSocket[] = [];
|
||||
// Sticky pause state: applied to current instances on `pause()` AND
|
||||
// to any new instance created later (e.g. WS reconnect after a
|
||||
// `disable-sync` / `reset` cycle). Without this, a test pausing the
|
||||
// WS before the agent reconnects would silently see the new socket
|
||||
// start un-paused and miss the messages it meant to buffer.
|
||||
private currentlyPaused = false;
|
||||
|
||||
public get constructorFn(): typeof globalThis.WebSocket {
|
||||
const trackInstance = (instance: ManagedWebSocket): void => {
|
||||
this.instances.push(instance);
|
||||
if (this.currentlyPaused) {
|
||||
instance.pause();
|
||||
}
|
||||
};
|
||||
class TrackedManagedWebSocket extends ManagedWebSocket {
|
||||
public constructor(
|
||||
url: string | URL,
|
||||
protocols?: string | string[]
|
||||
) {
|
||||
super(url, protocols);
|
||||
trackInstance(this);
|
||||
}
|
||||
}
|
||||
return TrackedManagedWebSocket;
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this.currentlyPaused = true;
|
||||
for (const ws of this.instances) {
|
||||
ws.pause();
|
||||
}
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
this.currentlyPaused = false;
|
||||
for (const ws of this.instances) {
|
||||
ws.resume();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import * as os from "node:os";
|
||||
import { Command, InvalidArgumentError } from "commander";
|
||||
|
||||
export interface CliArgs {
|
||||
filter: string | undefined;
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string): number {
|
||||
const n = parseInt(value, 10);
|
||||
if (isNaN(n) || n <= 0) {
|
||||
throw new InvalidArgumentError("must be a positive integer");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("deterministic-tests")
|
||||
.description("Scripted multi-client sync tests against a real server")
|
||||
.option(
|
||||
"-f, --filter <substring>",
|
||||
"Run only tests whose name contains this substring"
|
||||
)
|
||||
.option(
|
||||
"-j, --concurrency <number>",
|
||||
"Number of tests to run in parallel",
|
||||
parsePositiveInt,
|
||||
os.cpus().length
|
||||
);
|
||||
|
||||
program.parse(argv);
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||
const opts = program.opts();
|
||||
const filter = opts.filter as string | undefined;
|
||||
const concurrency = opts.concurrency as number;
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
return { filter, concurrency };
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { Logger } from "sync-client";
|
||||
|
||||
export class PrefixedLogger extends Logger {
|
||||
private readonly base: Logger;
|
||||
private readonly prefix: string;
|
||||
|
||||
public constructor(base: Logger, prefix: string) {
|
||||
super();
|
||||
this.base = base;
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
public override debug(message: string): void {
|
||||
this.base.debug(`[${this.prefix}] ${message}`);
|
||||
}
|
||||
|
||||
public override info(message: string): void {
|
||||
this.base.info(`[${this.prefix}] ${message}`);
|
||||
}
|
||||
|
||||
public override warn(message: string): void {
|
||||
this.base.warn(`[${this.prefix}] ${message}`);
|
||||
}
|
||||
|
||||
public override error(message: string): void {
|
||||
this.base.error(`[${this.prefix}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
export async function runWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
const errors: unknown[] = [];
|
||||
const executing = new Set<Promise<void>>();
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const index = i;
|
||||
const p = fn(items[index])
|
||||
.then((result) => {
|
||||
results[index] = result;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
errors.push(error);
|
||||
})
|
||||
.finally(() => executing.delete(p));
|
||||
executing.add(p);
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
await Promise.all(executing);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { sleep } from "./utils/sleep";
|
||||
import { findFreePort } from "./utils/find-free-port";
|
||||
import type { Logger } from "sync-client";
|
||||
import {
|
||||
STOP_TIMEOUT_MS,
|
||||
SERVER_READY_POLL_INTERVAL_MS,
|
||||
SERVER_READY_MAX_ATTEMPTS,
|
||||
SERVER_START_MAX_ATTEMPTS
|
||||
} from "./consts";
|
||||
|
||||
export class ServerControl {
|
||||
private process: ChildProcess | null = null;
|
||||
private readonly serverPath: string;
|
||||
private readonly baseConfigPath: string;
|
||||
private readonly logger: Logger;
|
||||
private _port: number | undefined;
|
||||
private tempDir: string | undefined;
|
||||
private _isPaused = false;
|
||||
|
||||
public constructor(serverPath: string, configPath: string, logger: Logger) {
|
||||
this.serverPath = serverPath;
|
||||
this.baseConfigPath = configPath;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public get port(): number {
|
||||
if (this._port === undefined) {
|
||||
throw new Error("Server has not been started yet");
|
||||
}
|
||||
return this._port;
|
||||
}
|
||||
|
||||
public get remoteUri(): string {
|
||||
return `http://localhost:${this.port}`;
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.process !== null) {
|
||||
throw new Error("Server is already running");
|
||||
}
|
||||
|
||||
// Retry on bind failure: findFreePort closes its probe before we
|
||||
// spawn, so under heavy parallelism another process can grab the
|
||||
// same port. Each attempt picks a fresh port.
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await this.startOnce();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.logger.warn(
|
||||
`Server start attempt ${attempt}/${SERVER_START_MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
// startOnce already cleaned up its child + tempdir on failure.
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Server failed to start after ${SERVER_START_MAX_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined }
|
||||
);
|
||||
}
|
||||
|
||||
private async startOnce(): Promise<void> {
|
||||
const reservation = await findFreePort();
|
||||
this._port = reservation.port;
|
||||
const tmpBase = os.tmpdir();
|
||||
this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-"));
|
||||
const tempConfigPath = path.join(this.tempDir, "config.yml");
|
||||
const dbDir = path.join(this.tempDir, "databases");
|
||||
|
||||
this.writeConfigFile(tempConfigPath, dbDir);
|
||||
|
||||
this.logger.info(
|
||||
`Starting server: ${this.serverPath} (port ${this._port})`
|
||||
);
|
||||
|
||||
// Release the port reservation right before spawning to minimize
|
||||
// the TOCTOU window between port discovery and server binding.
|
||||
reservation.release();
|
||||
|
||||
this.process = spawn(this.serverPath, [tempConfigPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false
|
||||
});
|
||||
|
||||
this.process.stdout?.on("data", (data: Buffer) => {
|
||||
this.logger.info(`[SERVER] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
this.process.stderr?.on("data", (data: Buffer) => {
|
||||
this.logger.info(`[SERVER] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
this.process.on("error", (err) => {
|
||||
this.logger.error(`[SERVER] Process error: ${err.message}`);
|
||||
});
|
||||
|
||||
const currentProcess = this.process;
|
||||
currentProcess.on("exit", (code, signal) => {
|
||||
this.logger.info(
|
||||
`Server exited with code ${code}, signal ${signal}`
|
||||
);
|
||||
// Only clear state if this handler is for the current process.
|
||||
// A fast stop→start cycle could create a new process before this
|
||||
// handler fires — clearing state here would corrupt the new one.
|
||||
if (this.process === currentProcess) {
|
||||
this.process = null;
|
||||
this._isPaused = false;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await this.waitForReady();
|
||||
} catch (error) {
|
||||
// Kill the spawned process if it failed to become ready,
|
||||
// preventing a zombie process from lingering.
|
||||
try {
|
||||
await this.stop();
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async waitForReady(
|
||||
maxAttempts: number = SERVER_READY_MAX_ATTEMPTS
|
||||
): Promise<void> {
|
||||
const pingUrl = `${this.remoteUri}/vaults/test/ping`;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (this.process?.exitCode !== null) {
|
||||
throw new Error(
|
||||
"Server process died while waiting for it to become ready"
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(pingUrl);
|
||||
if (response.ok) {
|
||||
this.logger.info("[SERVER] Ready");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Server not ready yet, continue polling
|
||||
}
|
||||
await sleep(SERVER_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error("Server failed to start within timeout");
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
if (this.process?.pid === undefined) {
|
||||
throw new Error("Server is not running");
|
||||
}
|
||||
if (this._isPaused) {
|
||||
this.logger.warn("Server is already paused, skipping double-pause");
|
||||
return;
|
||||
}
|
||||
this.logger.info("Server pausing...");
|
||||
try {
|
||||
process.kill(this.process.pid, "SIGSTOP");
|
||||
this._isPaused = true;
|
||||
this.logger.info("Server paused (SIGSTOP sent)");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to pause server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (this.process?.pid === undefined) {
|
||||
throw new Error("Server is not running");
|
||||
}
|
||||
if (!this._isPaused) {
|
||||
return;
|
||||
}
|
||||
this.logger.info("Server resuming...");
|
||||
try {
|
||||
process.kill(this.process.pid, "SIGCONT");
|
||||
this._isPaused = false;
|
||||
this.logger.info("Server resumed (SIGCONT sent)");
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to resume server (pid ${this.process.pid}): ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const proc = this.process;
|
||||
if (proc?.pid === undefined) {
|
||||
this.cleanupTempDir();
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume if paused — a SIGSTOP'd process ignores SIGKILL
|
||||
if (this._isPaused) {
|
||||
try {
|
||||
process.kill(proc.pid, "SIGCONT");
|
||||
} catch {
|
||||
// Process may already be gone
|
||||
}
|
||||
this._isPaused = false;
|
||||
}
|
||||
|
||||
this.logger.info("Server stopping...");
|
||||
|
||||
// Set up a promise that resolves when the process actually exits.
|
||||
const exitPromise = new Promise<void>((resolve) => {
|
||||
if (proc.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
proc.on("exit", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already gone
|
||||
}
|
||||
|
||||
// Wait for the process to actually exit before cleaning up,
|
||||
// with a 5s safety timeout to avoid hanging forever.
|
||||
await Promise.race([exitPromise, sleep(STOP_TIMEOUT_MS)]);
|
||||
|
||||
this.process = null;
|
||||
this._isPaused = false;
|
||||
this.cleanupTempDir();
|
||||
}
|
||||
|
||||
public isRunning(): boolean {
|
||||
const proc = this.process;
|
||||
return (
|
||||
proc !== null &&
|
||||
proc.pid !== undefined &&
|
||||
proc.exitCode === null &&
|
||||
proc.signalCode === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously SIGCONT-then-SIGKILL the child process. Safe to call
|
||||
* from a `process.on("exit", ...)` handler, where async work cannot
|
||||
* run. Used as a last-resort cleanup so a SIGSTOP'd server doesn't
|
||||
* outlive the test runner and wedge the next CI invocation.
|
||||
*/
|
||||
public forceKillSync(): void {
|
||||
const proc = this.process;
|
||||
if (proc?.pid === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
process.kill(proc.pid, "SIGCONT");
|
||||
} catch {
|
||||
// Process may already be gone or never paused.
|
||||
}
|
||||
try {
|
||||
process.kill(proc.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Process already gone.
|
||||
}
|
||||
}
|
||||
|
||||
private writeConfigFile(destPath: string, dbDir: string): void {
|
||||
// Assumes config-e2e.yml has exactly one 2-space-indented `port:` and
|
||||
// one `databases_directory_path:` (under `server:` and `database:`
|
||||
// respectively)
|
||||
const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8");
|
||||
const config = baseConfig
|
||||
.replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`)
|
||||
.replace(
|
||||
/^\s*databases_directory_path:\s*.+/m,
|
||||
` databases_directory_path: ${dbDir}`
|
||||
);
|
||||
fs.writeFileSync(destPath, config);
|
||||
}
|
||||
|
||||
private cleanupTempDir(): void {
|
||||
if (this.tempDir !== undefined) {
|
||||
try {
|
||||
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
this.tempDir = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import type { ServerControl } from "./server-control";
|
||||
import type { Logger } from "sync-client";
|
||||
|
||||
export class ServerManager {
|
||||
private readonly activeServers = new Set<ServerControl>();
|
||||
private readonly logger: Logger;
|
||||
private isShuttingDown = false;
|
||||
|
||||
public constructor(logger: Logger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public track(server: ServerControl): void {
|
||||
this.activeServers.add(server);
|
||||
}
|
||||
|
||||
public untrack(server: ServerControl): void {
|
||||
this.activeServers.delete(server);
|
||||
}
|
||||
|
||||
public async stopAll(): Promise<void> {
|
||||
if (this.isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
|
||||
const servers = Array.from(this.activeServers);
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
await Promise.all(
|
||||
servers.map(async (server) => {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// Best-effort cleanup during shutdown
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public installSignalHandlers(): void {
|
||||
process.on("SIGINT", () => {
|
||||
this.logger.info("Received SIGINT, shutting down...");
|
||||
void this.stopAll()
|
||||
.catch(() => {
|
||||
/* no-op */
|
||||
})
|
||||
.then(() => process.exit(130));
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
this.logger.info("Received SIGTERM, shutting down...");
|
||||
void this.stopAll()
|
||||
.catch(() => {
|
||||
/* no-op */
|
||||
})
|
||||
.then(() => process.exit(143));
|
||||
});
|
||||
|
||||
// Last-resort synchronous cleanup. Runs even when the process is
|
||||
// exiting via process.exit() from unhandledRejection /
|
||||
// uncaughtException — paths where async stopAll() cannot complete.
|
||||
// SIGSTOP'd servers MUST receive SIGCONT before SIGKILL or the
|
||||
// kernel keeps them as zombies holding the test's tmpdir, and the
|
||||
// next CI run can't reuse the port.
|
||||
process.on("exit", () => {
|
||||
for (const server of this.activeServers) {
|
||||
server.forceKillSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "./utils/assertable-state";
|
||||
|
||||
export interface ClientState {
|
||||
files: Map<string, string>;
|
||||
clientFiles: Map<string, string>[];
|
||||
}
|
||||
|
||||
export type TestStep =
|
||||
| { type: "create"; client: number; path: string; content: string }
|
||||
| { type: "update"; client: number; path: string; content: string }
|
||||
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
||||
| {
|
||||
type: "rename-next-write";
|
||||
client: number;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
| { type: "delete"; client: number; path: string }
|
||||
| { type: "sync"; client?: number }
|
||||
| { type: "disable-sync"; client: number }
|
||||
| { type: "enable-sync"; client: number }
|
||||
| { type: "pause-server" }
|
||||
| { type: "resume-server" }
|
||||
| {
|
||||
type: "resume-server-until-history-then-pause";
|
||||
client: number;
|
||||
syncType: "CREATE" | "UPDATE" | "DELETE";
|
||||
path: string;
|
||||
}
|
||||
| { type: "barrier" }
|
||||
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }
|
||||
| { type: "pause-websocket"; client: number }
|
||||
| { type: "resume-websocket"; client: number }
|
||||
| { type: "drop-next-create-response"; client: number }
|
||||
| { type: "wait-for-dropped-create-response"; client: number }
|
||||
| { type: "sleep"; ms: number }
|
||||
| { type: "reset"; client: number };
|
||||
|
||||
export interface TestDefinition {
|
||||
description?: string;
|
||||
clients: number;
|
||||
steps: TestStep[];
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
import type { TestDefinition } from "./test-definition";
|
||||
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
|
||||
import { renameChainTest } from "./tests/rename-chain.test";
|
||||
import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test";
|
||||
import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test";
|
||||
import { multiFileOperationsTest } from "./tests/multi-file-operations.test";
|
||||
import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test";
|
||||
import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test";
|
||||
import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test";
|
||||
import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test";
|
||||
import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test";
|
||||
import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test";
|
||||
import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test";
|
||||
import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test";
|
||||
import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test";
|
||||
import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test";
|
||||
import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test";
|
||||
import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test";
|
||||
import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test";
|
||||
import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test";
|
||||
import { renameSwapTest } from "./tests/rename-swap.test";
|
||||
import { renameCircularTest } from "./tests/rename-circular.test";
|
||||
import { renameRoundtripTest } from "./tests/rename-roundtrip.test";
|
||||
import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test";
|
||||
import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test";
|
||||
import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test";
|
||||
import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test";
|
||||
import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test";
|
||||
import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test";
|
||||
import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test";
|
||||
import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test";
|
||||
import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test";
|
||||
import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test";
|
||||
import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test";
|
||||
import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test";
|
||||
import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-mergeable.test";
|
||||
import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test";
|
||||
import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test";
|
||||
import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test";
|
||||
import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test";
|
||||
import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test";
|
||||
import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test";
|
||||
import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test";
|
||||
import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test";
|
||||
import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test";
|
||||
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
|
||||
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
|
||||
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
|
||||
import { updateDoesNotSurviveRemoteDeleteTest } from "./tests/update-does-not-survive-remote-delete.test";
|
||||
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
|
||||
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
|
||||
import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test";
|
||||
import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test";
|
||||
import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test";
|
||||
import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test";
|
||||
import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test";
|
||||
import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test";
|
||||
import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test";
|
||||
import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test";
|
||||
import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
|
||||
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
|
||||
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
|
||||
import { textPendingCreateNotDisplacedTest } from "./tests/text-pending-create-not-displaced.test";
|
||||
import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test";
|
||||
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test";
|
||||
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test";
|
||||
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test";
|
||||
import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test";
|
||||
import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test";
|
||||
import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test";
|
||||
import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test";
|
||||
import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test";
|
||||
import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test";
|
||||
import { createDeleteNoopTest } from "./tests/create-delete-noop.test";
|
||||
import { createMergeDeleteTest } from "./tests/create-merge-delete.test";
|
||||
import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test";
|
||||
import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test";
|
||||
import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test";
|
||||
import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test";
|
||||
import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test";
|
||||
import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test";
|
||||
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
|
||||
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
|
||||
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
|
||||
import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test";
|
||||
import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test";
|
||||
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test";
|
||||
import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
|
||||
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
|
||||
import { remoteUpdateResurrectsDeletedDocTest } from "./tests/remote-update-resurrects-deleted-doc.test";
|
||||
import { localUpdateSurvivesRemoteRenameTest } from "./tests/local-update-survives-remote-rename.test";
|
||||
import { mergingUpdateResponseSurvivesUserRenameTest } from "./tests/merging-update-response-survives-user-rename.test";
|
||||
import { catchupCreateAndUpdateNotSkippedTest } from "./tests/catchup-create-and-update-not-skipped.test";
|
||||
import { localRenameSurvivesRemoteRenameTest } from "./tests/local-rename-survives-remote-rename.test";
|
||||
import { renameChainDuringPendingCreateTest } from "./tests/rename-chain-during-pending-create.test";
|
||||
import { remoteRenameCollidesWithPendingLocalCreateTest } from "./tests/remote-rename-collides-with-pending-local-create.test";
|
||||
import { remoteUpdateSurvivesUserRenameTest } from "./tests/remote-update-survives-user-rename.test";
|
||||
import { sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest } from "./tests/same-doc-id-collapse-on-local-create-after-remote-create.test";
|
||||
import { sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest } from "./tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test";
|
||||
import { renameOverwritesPendingCreateThenDeleteTest } from "./tests/rename-overwrites-pending-create-then-delete.test";
|
||||
import { deleteRecreatedPendingCreateWithStaleDeletingRecordTest } from "./tests/delete-recreated-pending-create-with-stale-deleting-record.test";
|
||||
import { queuedCreateDeleteDoesNotHijackReusedPathTest } from "./tests/queued-create-delete-does-not-hijack-reused-path.test";
|
||||
import { renamedPendingCreateReusedPathThenDeleteTest } from "./tests/renamed-pending-create-reused-path-then-delete.test";
|
||||
import { renamePendingCreateOntoPendingDeletePathTest } from "./tests/rename-pending-create-onto-pending-delete-path.test";
|
||||
import { remoteQuickWriteRenameBeforeRecordTest } from "./tests/remote-quick-write-rename-before-record.test";
|
||||
import { selfMergePendingRenameAliasesSecondCreateTest } from "./tests/self-merge-pending-rename-aliases-second-create.test";
|
||||
|
||||
export const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||
"rename-create-conflict": renameCreateConflictTest,
|
||||
"rename-chain": renameChainTest,
|
||||
"rename-update-conflict": renameUpdateConflictTest,
|
||||
"delete-rename-conflict": deleteRenameConflictTest,
|
||||
"multi-file-operations": multiFileOperationsTest,
|
||||
"delete-recreate-same-path": deleteRecreateSamePathTest,
|
||||
"offline-rename-and-edit": offlineRenameAndEditTest,
|
||||
"simultaneous-create-delete-same-path":
|
||||
simultaneousCreateDeleteSamePathTest,
|
||||
"idempotency-after-server-pause": idempotencyAfterServerPauseTest,
|
||||
"sequential-create-duplicate-content": sequentialCreateDuplicateContentTest,
|
||||
"mc-three-client-rename-offline-update":
|
||||
mcThreeClientRenameOfflineUpdateTest,
|
||||
"mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest,
|
||||
"mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest,
|
||||
"mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest,
|
||||
"offline-mixed-operations": offlineMixedOperationsTest,
|
||||
"offline-concurrent-renames": offlineConcurrentRenamesTest,
|
||||
"offline-multiple-edits": offlineMultipleEditsTest,
|
||||
"server-pause-both-clients-create": serverPauseBothClientsCreateTest,
|
||||
"server-pause-update-and-create": serverPauseUpdateAndCreateTest,
|
||||
"rename-swap": renameSwapTest,
|
||||
"rename-circular": renameCircularTest,
|
||||
"rename-roundtrip": renameRoundtripTest,
|
||||
"offline-rename-remote-create-old-path":
|
||||
offlineRenameRemoteCreateOldPathTest,
|
||||
"offline-edit-remote-rename": offlineEditRemoteRenameTest,
|
||||
"rename-chain-then-delete": renameChainThenDeleteTest,
|
||||
"offline-delete-remote-rename": offlineDeleteRemoteRenameTest,
|
||||
"overlapping-edits-same-section": overlappingEditsSameSectionTest,
|
||||
"rapid-updates-after-merge": rapidUpdatesAfterMergeTest,
|
||||
"delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest,
|
||||
"move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest,
|
||||
"double-offline-cycle": doubleOfflineCycleTest,
|
||||
"server-pause-rename-edit-resume": serverPauseRenameEditResumeTest,
|
||||
"offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest,
|
||||
"offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest,
|
||||
"delete-during-pending-create": deleteDuringPendingCreateTest,
|
||||
"three-client-rename-create-delete": threeClientRenameCreateDeleteTest,
|
||||
"rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest,
|
||||
"offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest,
|
||||
"rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest,
|
||||
"server-pause-both-edit-same-file": serverPauseBothEditSameFileTest,
|
||||
"delete-recreate-different-content": deleteRecreateDifferentContentTest,
|
||||
"update-during-create-processing": updateDuringCreateProcessingTest,
|
||||
"offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest,
|
||||
"reset-clears-recently-deleted-resurrection":
|
||||
resetClearsRecentlyDeletedResurrectionTest,
|
||||
"move-then-delete-stale-path": moveThenDeleteStalePathTest,
|
||||
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
|
||||
"interrupted-delete-retry": interruptedDeleteRetryTest,
|
||||
"update-does-not-survive-remote-delete": updateDoesNotSurviveRemoteDeleteTest,
|
||||
"move-preserves-remote-update": movePreservesRemoteUpdateTest,
|
||||
"recently-deleted-cleared-on-reconnect":
|
||||
recentlyDeletedClearedOnReconnectTest,
|
||||
"watermark-advances-on-skip": watermarkAdvancesOnSkipTest,
|
||||
"watermark-gap-remote-update-not-recorded":
|
||||
watermarkGapRemoteUpdateNotRecordedTest,
|
||||
"queue-reset-loses-coalesced-local-edit":
|
||||
queueResetLosesCoalescedLocalEditTest,
|
||||
"rename-to-pending-path-fallback": renameToPendingPathFallbackTest,
|
||||
"move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest,
|
||||
"local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest,
|
||||
"rename-pending-create-before-response":
|
||||
renamePendingCreateBeforeResponseTest,
|
||||
"create-rename-response-skips-file": createRenameResponseSkipsFileTest,
|
||||
"online-create-rename-concurrent-create-orphan":
|
||||
onlineCreateRenameConcurrentCreateOrphanTest,
|
||||
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
|
||||
"binary-to-text-transition": binaryToTextTransitionTest,
|
||||
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
|
||||
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
|
||||
"coalesce-update-remote-update-data-loss":
|
||||
coalesceUpdateRemoteUpdateDataLossTest,
|
||||
"coalesced-remote-update-watermark-loss":
|
||||
coalescedRemoteUpdateWatermarkLossTest,
|
||||
"concurrent-delete-during-remote-update":
|
||||
concurrentDeleteDuringRemoteUpdateTest,
|
||||
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
|
||||
"concurrent-rename-and-create-at-target-rename-first":
|
||||
concurrentRenameAndCreateAtTargetRenameFirstTest,
|
||||
"concurrent-rename-and-create-at-target-create-first":
|
||||
concurrentRenameAndCreateAtTargetCreateFirstTest,
|
||||
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
|
||||
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
|
||||
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
|
||||
"create-delete-noop": createDeleteNoopTest,
|
||||
"create-merge-delete": createMergeDeleteTest,
|
||||
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
|
||||
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
|
||||
"create-during-reconciliation": createDuringReconciliationTest,
|
||||
"create-merge-preserves-renamed-update":
|
||||
createMergePreservesRenamedUpdateTest,
|
||||
"create-rename-create-same-path": createRenameCreateSamePathTest,
|
||||
"move-chain-three-files": moveChainThreeFilesTest,
|
||||
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
|
||||
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
|
||||
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
|
||||
"rapid-edit-delete-online-convergence":
|
||||
rapidEditDeleteOnlineConvergenceTest,
|
||||
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
|
||||
"online-both-create-same-path-deconflict":
|
||||
onlineBothCreateSamePathDeconflictTest,
|
||||
"online-create-update-while-other-creates-same-path":
|
||||
onlineCreateUpdateWhileOtherCreatesSamePathTest,
|
||||
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
|
||||
"remote-update-resurrects-deleted-doc":
|
||||
remoteUpdateResurrectsDeletedDocTest,
|
||||
"local-update-survives-remote-rename": localUpdateSurvivesRemoteRenameTest,
|
||||
"merging-update-response-survives-user-rename":
|
||||
mergingUpdateResponseSurvivesUserRenameTest,
|
||||
"catchup-create-and-update-not-skipped":
|
||||
catchupCreateAndUpdateNotSkippedTest,
|
||||
"local-rename-survives-remote-rename": localRenameSurvivesRemoteRenameTest,
|
||||
"rename-chain-during-pending-create": renameChainDuringPendingCreateTest,
|
||||
"remote-rename-collides-with-pending-local-create":
|
||||
remoteRenameCollidesWithPendingLocalCreateTest,
|
||||
"remote-update-survives-user-rename": remoteUpdateSurvivesUserRenameTest,
|
||||
"same-doc-id-collapse-on-local-create-after-remote-create":
|
||||
sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest,
|
||||
"renamed-pending-create-reused-path-then-delete":
|
||||
renamedPendingCreateReusedPathThenDeleteTest,
|
||||
"rename-pending-create-onto-pending-delete-path":
|
||||
renamePendingCreateOntoPendingDeletePathTest,
|
||||
"rename-overwrites-pending-create-then-delete":
|
||||
renameOverwritesPendingCreateThenDeleteTest,
|
||||
"same-doc-id-collapse-after-remote-quick-write-and-pending-rename":
|
||||
sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest,
|
||||
"delete-recreated-pending-create-with-stale-deleting-record":
|
||||
deleteRecreatedPendingCreateWithStaleDeletingRecordTest,
|
||||
"queued-create-delete-does-not-hijack-reused-path":
|
||||
queuedCreateDeleteDoesNotHijackReusedPathTest,
|
||||
"remote-quick-write-rename-before-record":
|
||||
remoteQuickWriteRenameBeforeRecordTest,
|
||||
"self-merge-pending-rename-aliases-second-create":
|
||||
selfMergePendingRenameAliasesSecondCreateTest
|
||||
};
|
||||
|
|
@ -1,399 +0,0 @@
|
|||
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
|
||||
import { DeterministicAgent } from "./deterministic-agent";
|
||||
import type { ServerControl } from "./server-control";
|
||||
import type { SyncSettings, Logger } from "sync-client";
|
||||
import { assert } from "./utils/assert";
|
||||
import { AssertableState } from "./utils/assertable-state";
|
||||
import { sleep } from "./utils/sleep";
|
||||
import { withTimeout } from "./utils/with-timeout";
|
||||
import {
|
||||
CONVERGENCE_TIMEOUT_MS,
|
||||
CONVERGENCE_RETRY_DELAY_MS,
|
||||
AGENT_INIT_TIMEOUT_MS,
|
||||
IS_SYNC_ENABLED_BY_DEFAULT
|
||||
} from "./consts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export class TestRunner {
|
||||
private agents: DeterministicAgent[] = [];
|
||||
private readonly serverControl: ServerControl;
|
||||
private readonly token: string;
|
||||
private readonly remoteUri: string;
|
||||
private readonly logger: Logger;
|
||||
|
||||
public constructor(
|
||||
serverControl: ServerControl,
|
||||
logger: Logger,
|
||||
token: string,
|
||||
remoteUri: string
|
||||
) {
|
||||
this.serverControl = serverControl;
|
||||
this.logger = logger;
|
||||
this.token = token;
|
||||
this.remoteUri = remoteUri;
|
||||
}
|
||||
|
||||
public async runTest(
|
||||
name: string,
|
||||
test: TestDefinition
|
||||
): Promise<TestResult> {
|
||||
const startTime = Date.now();
|
||||
this.logger.info(`Running test: ${name}`);
|
||||
if (test.description !== undefined && test.description !== "") {
|
||||
this.logger.info(`Description: ${test.description}`);
|
||||
}
|
||||
this.logger.info(`Clients: ${test.clients}`);
|
||||
this.logger.info(`Steps: ${test.steps.length}`);
|
||||
|
||||
try {
|
||||
assert(
|
||||
this.serverControl.isRunning(),
|
||||
"Server is not running before test start"
|
||||
);
|
||||
|
||||
await this.initializeAgents(test.clients);
|
||||
|
||||
for (let i = 0; i < test.steps.length; i++) {
|
||||
const step = test.steps[i];
|
||||
this.logger.info(
|
||||
`Step ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
|
||||
);
|
||||
await this.executeStep(step);
|
||||
}
|
||||
|
||||
await this.cleanup();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
duration
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.logger.info(`\n✗ Test failed: ${name}`);
|
||||
this.logger.info(`Error: ${errorMessage}`);
|
||||
|
||||
await this.cleanup();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
duration
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeAgents(count: number): Promise<void> {
|
||||
assert(count > 0, `Client count must be positive, got ${count}`);
|
||||
const vaultName = `test-${randomUUID()}`;
|
||||
this.logger.info(
|
||||
`Initializing ${count} agents with vault: ${vaultName}`
|
||||
);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const settings: Partial<SyncSettings> = {
|
||||
isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT,
|
||||
token: this.token,
|
||||
vaultName,
|
||||
remoteUri: this.remoteUri
|
||||
};
|
||||
|
||||
const agent = new DeterministicAgent(i, settings, (msg) => {
|
||||
this.logger.info(msg);
|
||||
});
|
||||
|
||||
// Push before init so cleanup() handles this agent if init fails
|
||||
this.agents.push(agent);
|
||||
await withTimeout(
|
||||
agent.init(fetch),
|
||||
AGENT_INIT_TIMEOUT_MS,
|
||||
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
|
||||
);
|
||||
this.logger.info(`Initialized client ${i}`);
|
||||
}
|
||||
|
||||
this.logger.info("All agents initialized");
|
||||
}
|
||||
|
||||
private getAgent(index: number): DeterministicAgent {
|
||||
assert(
|
||||
index >= 0 && index < this.agents.length,
|
||||
`Client index ${index} out of bounds (have ${this.agents.length} agents)`
|
||||
);
|
||||
return this.agents[index];
|
||||
}
|
||||
|
||||
private async executeStep(step: TestStep): Promise<void> {
|
||||
switch (step.type) {
|
||||
case "create":
|
||||
case "update":
|
||||
await this.getAgent(step.client).write(
|
||||
step.path,
|
||||
new TextEncoder().encode(step.content)
|
||||
);
|
||||
break;
|
||||
|
||||
case "rename":
|
||||
await this.getAgent(step.client).rename(
|
||||
step.oldPath,
|
||||
step.newPath
|
||||
);
|
||||
break;
|
||||
|
||||
case "rename-next-write":
|
||||
this.getAgent(step.client).renameNextWrite(
|
||||
step.oldPath,
|
||||
step.newPath
|
||||
);
|
||||
break;
|
||||
|
||||
case "delete":
|
||||
await this.getAgent(step.client).delete(step.path);
|
||||
break;
|
||||
|
||||
case "sync":
|
||||
if (step.client !== undefined) {
|
||||
await this.getAgent(step.client).waitForSync();
|
||||
} else {
|
||||
for (const agent of this.agents) {
|
||||
await agent.waitForSync();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "disable-sync":
|
||||
await this.getAgent(step.client).disableSync();
|
||||
break;
|
||||
|
||||
case "enable-sync":
|
||||
await this.getAgent(step.client).enableSync();
|
||||
break;
|
||||
|
||||
case "pause-server":
|
||||
this.serverControl.pause();
|
||||
break;
|
||||
|
||||
case "resume-server":
|
||||
this.serverControl.resume();
|
||||
// Verify the server is actually responsive before proceeding.
|
||||
// This replaces relying solely on hardcoded waits.
|
||||
await this.serverControl.waitForReady();
|
||||
break;
|
||||
|
||||
case "resume-server-until-history-then-pause": {
|
||||
const agent = this.getAgent(step.client);
|
||||
const historySeen = agent.waitForHistoryEntry(
|
||||
(entry) =>
|
||||
entry.details.type === step.syncType &&
|
||||
entry.details.relativePath === step.path,
|
||||
() => this.serverControl.pause()
|
||||
);
|
||||
this.serverControl.resume();
|
||||
await historySeen;
|
||||
break;
|
||||
}
|
||||
|
||||
case "barrier":
|
||||
await this.waitForConvergence();
|
||||
break;
|
||||
|
||||
case "assert-consistent":
|
||||
await this.assertConsistent(step.verify);
|
||||
break;
|
||||
|
||||
case "pause-websocket":
|
||||
this.getAgent(step.client).pauseWebSocket();
|
||||
break;
|
||||
|
||||
case "resume-websocket":
|
||||
this.getAgent(step.client).resumeWebSocket();
|
||||
break;
|
||||
|
||||
case "drop-next-create-response":
|
||||
this.getAgent(step.client).dropNextCreateResponse();
|
||||
break;
|
||||
|
||||
case "wait-for-dropped-create-response":
|
||||
await this.getAgent(step.client).waitForDroppedCreateResponse();
|
||||
break;
|
||||
|
||||
case "sleep":
|
||||
await sleep(step.ms);
|
||||
break;
|
||||
|
||||
case "reset":
|
||||
await this.getAgent(step.client).reset();
|
||||
break;
|
||||
|
||||
default: {
|
||||
const unknownStep = step as { type: string };
|
||||
throw new Error(`Unknown step type: ${unknownStep.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all agents to reach a consistent state.
|
||||
*
|
||||
* Waiting for agents is done in two full rounds: the first round
|
||||
* drains in-flight operations, but completing those operations can
|
||||
* trigger new work on OTHER agents via server broadcasts. The second
|
||||
* round waits for that cascading work to settle. Deeper cascades
|
||||
* are handled by the outer retry loop.
|
||||
*/
|
||||
private async waitForConvergence(): Promise<void> {
|
||||
this.logger.info("Barrier: waiting for convergence...");
|
||||
|
||||
const deadline = Date.now() + CONVERGENCE_TIMEOUT_MS;
|
||||
let lastError: Error | undefined = undefined;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await this.waitAllAgentsSettled();
|
||||
|
||||
try {
|
||||
await this.assertConsistent();
|
||||
this.logger.info("Barrier complete: all clients converged");
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError =
|
||||
error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.info("Barrier: not yet converged, retrying...");
|
||||
await sleep(CONVERGENCE_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`,
|
||||
{ cause: lastError }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all agents to be simultaneously idle.
|
||||
*
|
||||
* Completing work on agent A can trigger a server broadcast that
|
||||
* enqueues new work on agent B, which can cascade further. With N
|
||||
* agents the worst-case cascade depth is N (a chain A→B→C→…→A),
|
||||
* so we run N+1 sequential passes to drain it. Extra passes are
|
||||
* essentially free when there is no outstanding work.
|
||||
*
|
||||
* The outer {@link waitForConvergence} loop with consistency checks
|
||||
* remains the ultimate guarantee — this method just minimizes how
|
||||
* many slow retry iterations are needed.
|
||||
*/
|
||||
private async waitAllAgentsSettled(): Promise<void> {
|
||||
const rounds = this.agents.length + 1;
|
||||
for (let round = 0; round < rounds; round++) {
|
||||
for (const agent of this.agents) {
|
||||
await agent.waitForSync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertConsistent(
|
||||
verify?: (state: AssertableState) => void
|
||||
): Promise<void> {
|
||||
this.logger.info("Asserting all clients are consistent...");
|
||||
assert(
|
||||
this.agents.length >= 2,
|
||||
"Need at least 2 agents for consistency check"
|
||||
);
|
||||
|
||||
// Snapshot all agents' file states upfront to minimize the window
|
||||
// where background sync could mutate state between reads.
|
||||
const clientFiles: Map<string, string>[] = [];
|
||||
for (const agent of this.agents) {
|
||||
const sortedFiles = (await agent.listFilesRecursively()).sort();
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const file of sortedFiles) {
|
||||
const content = await agent.getFileContent(file);
|
||||
fileMap.set(file, content);
|
||||
}
|
||||
clientFiles.push(fileMap);
|
||||
}
|
||||
|
||||
const referenceFiles = Array.from(clientFiles[0].keys());
|
||||
|
||||
this.logger.info(
|
||||
`Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
|
||||
);
|
||||
|
||||
for (let i = 1; i < clientFiles.length; i++) {
|
||||
const agentFileKeys = Array.from(clientFiles[i].keys());
|
||||
|
||||
this.logger.info(
|
||||
`Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}`
|
||||
);
|
||||
|
||||
assert(
|
||||
agentFileKeys.length === referenceFiles.length,
|
||||
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files`
|
||||
);
|
||||
|
||||
for (let j = 0; j < agentFileKeys.length; j++) {
|
||||
assert(
|
||||
agentFileKeys[j] === referenceFiles[j],
|
||||
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"`
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of referenceFiles) {
|
||||
const referenceContent = clientFiles[0].get(file);
|
||||
const agentContent = clientFiles[i].get(file);
|
||||
|
||||
assert(
|
||||
referenceContent === agentContent,
|
||||
`Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info("✓ All clients are consistent");
|
||||
|
||||
if (verify) {
|
||||
this.logger.info("Running custom verification...");
|
||||
try {
|
||||
verify(
|
||||
new AssertableState({
|
||||
files: clientFiles[0],
|
||||
clientFiles
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Custom verification failed: ${msg}`);
|
||||
}
|
||||
this.logger.info("✓ Custom verification passed");
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
// Always resume the server in case a test paused it and then
|
||||
// failed before reaching the resume step. Without this, all
|
||||
// subsequent tests would hang because the server process is
|
||||
// frozen (SIGSTOP) and can't respond to HTTP or WebSocket.
|
||||
try {
|
||||
this.serverControl.resume();
|
||||
} catch {
|
||||
// Server wasn't paused or isn't running — safe to ignore
|
||||
}
|
||||
|
||||
this.logger.info("\nCleaning up agents...");
|
||||
for (const agent of this.agents) {
|
||||
try {
|
||||
await agent.cleanup();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Agent cleanup error: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
this.agents = [];
|
||||
this.logger.info("Cleanup complete");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||
description:
|
||||
"Two clients each create a binary file at the same path while offline. " +
|
||||
"After syncing, both files should exist on both clients at separate paths.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "binary data from client 0"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "data.bin",
|
||||
content: "binary data from client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertFileExists("data.bin")
|
||||
.assertFileExists("data (1).bin")
|
||||
.assertAnyFileContains(
|
||||
"binary data from client 0",
|
||||
"binary data from client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const binaryToTextTransitionTest: TestDefinition = {
|
||||
description:
|
||||
"A .bin file is created and synced. Both clients edit it offline " +
|
||||
"(binary last-write-wins), then client 0 renames it to .md and " +
|
||||
"writes a clean text baseline. Both clients edit different sections " +
|
||||
"offline. The text merge should preserve both edits.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("data.bin", "original content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "update", client: 0, path: "data.bin", content: "version A" },
|
||||
{ type: "update", client: 1, path: "data.bin", content: "version B" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContainsAny(
|
||||
"data.bin",
|
||||
"version A",
|
||||
"version B"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "data.md",
|
||||
content: "top line\nmiddle line\nbottom line"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent(
|
||||
"data.md",
|
||||
"top line\nmiddle line\nbottom line"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "data.md",
|
||||
content: "alpha\nmiddle line\nbottom line"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "data.md",
|
||||
content: "top line\nmiddle line\nbeta"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains("data.md", "alpha", "beta");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const catchupCreateAndUpdateNotSkippedTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 disconnects (sync disabled). Client 0 creates a doc and " +
|
||||
"then updates it. When Client 1 reconnects, the server's catch-up " +
|
||||
"stream sends only the doc's *latest* version (the update), not the " +
|
||||
"full history. Pre-fix the wire's `is_new_file` was set to " +
|
||||
"`creation == latest_version`, so the catch-up flagged the doc as " +
|
||||
"non-new even though Client 1 had never seen its creation. Client " +
|
||||
"1's `processRemoteChange` then dropped it as a 'stale RemoteChange " +
|
||||
"for untracked, non-new document' and the doc was silently lost. " +
|
||||
"Post-fix `is_new_file` in the catch-up stream means 'new relative " +
|
||||
"to the recipient's watermark' (`creation > last_seen_vault_update_id`).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
// Establish a baseline so Client 1's last_seen is non-zero before
|
||||
// we take it offline. This makes the bug genuinely about catch-up
|
||||
// missing the create rather than just an empty-vault first sync.
|
||||
{ type: "create", client: 0, path: "warmup.md", content: "w\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Client 1 goes offline.
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Client 0 creates the doc (vault_update_id v_C, after Client 1's
|
||||
// watermark). Client 1 doesn't see this because it's offline.
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
// Wait for the create's HTTP to land before the update; otherwise
|
||||
// both writes are coalesced into a single POST and the server
|
||||
// never sees the doc as "create followed by update".
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 0 updates the doc (vault_update_id v_X > v_C). The
|
||||
// server's `latest_document_versions` view now returns the
|
||||
// *update* row — its `creation_vault_update_id != vault_update_id`.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "v1\nupdate\n"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Client 1 reconnects. Server's catch-up replays docs with
|
||||
// `vault_update_id > last_seen`. For doc.md it sends v_X with
|
||||
// `is_new_file` derived from `creation_vault_update_id >
|
||||
// last_seen_vault_update_id` (post-fix) — so Client 1 treats it
|
||||
// as a fresh create and downloads the latest content.
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
state.assertFileExists("doc.md");
|
||||
state.assertContent("doc.md", "v1\nupdate\n");
|
||||
state.assertContent("warmup.md", "w\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = {
|
||||
description:
|
||||
"Divergent offline edits with text-merge expectation. Client 0's " +
|
||||
"remote update fully lands before Client 1 reconnects (`sync`-after " +
|
||||
"the c0 update enforces this), so Client 1's offline edit merges " +
|
||||
"against a server-known version, not a coalesced batch. Both " +
|
||||
"additions must survive in the final merged content. (Filename's " +
|
||||
"'coalesce' framing is aspirational — a true update-coalesce test " +
|
||||
"would skip the c0 sync and queue overlapping local + remote " +
|
||||
"updates against the same parent version.)",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "line 1\nline 2\nline 3"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "line 1\nline 2\nline 3\nclient 0 addition"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "client 1 addition\nline 1\nline 2\nline 3"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains(
|
||||
"doc.md",
|
||||
"client 0 addition",
|
||||
"client 1 addition"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 sends three rapid updates. After syncing, both clients " +
|
||||
"disconnect and reconnect twice. Content should remain correct " +
|
||||
"after each reconnect.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "final update" },
|
||||
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"One client updates a file while the other deletes it at the same " +
|
||||
"time. Both clients should converge without errors.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "update", client: 0, path: "doc.md", content: "updated by 0" },
|
||||
{ type: "delete", client: 1, path: "doc.md" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentEditExactSamePositionTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients replace the same word in a file with different text " +
|
||||
"while offline. After syncing, the merged result should contain " +
|
||||
"both replacements.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the quick brown fox"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the slow brown fox"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "the fast brown fox"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("doc.md", "slow", "fast", "brown fox");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames X to Y while another creates a new file at Y, " +
|
||||
"both offline. After syncing, Y should contain merged content from " +
|
||||
"both the renamed file and the newly created file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original file X"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "Y.md",
|
||||
content: "brand new Y content"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContains("Y (1).md", "original file X")
|
||||
.assertContains("Y.md", "brand new Y content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames X to Y while another creates a new file at Y, " +
|
||||
"both offline. We can't merge the create because it would result in a cycle",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "X.md",
|
||||
content: "original file X"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "Y.md",
|
||||
content: "brand new Y content"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileExists("Y.md")
|
||||
.assertFileExists("Y (1).md")
|
||||
.assertAnyFileContains(
|
||||
"original file X",
|
||||
"brand new Y content"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameFirstWinsTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients start online with the same file. Both go offline, " +
|
||||
"rename the file to different paths, and edit it. When they reconnect, " +
|
||||
"the first rename to reach the server wins the path and both content " +
|
||||
"edits are merged.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "line 1\nline 2\nline 3"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "line 1\nline 2\nline 3");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "edit from 0\nline 2\nline 3"
|
||||
},
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "C.md",
|
||||
content: "line 1\nline 2\nedit from 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "edit from 0\nline 2\nline 3")
|
||||
.assertContent("C.md", "line 1\nline 2\nedit from 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||
description:
|
||||
"One client renames A to C while the other renames B to C, both offline. " +
|
||||
"After syncing, both file contents should be preserved via path deconfliction.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "content-b" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertFileExists("C.md")
|
||||
.assertFileExists("C (1).md")
|
||||
.assertAnyFileContains("content-a", "content-b");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const concurrentUpdateDiffConsistencyTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients edit different sections of the same file while offline. " +
|
||||
"After syncing, the merged file should contain both edits.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "header\nmiddle\nfooter"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "header by 0\nmiddle\nfooter"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "header\nmiddle\nfooter by 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent(
|
||||
"doc.md",
|
||||
"header by 0\nmiddle\nfooter by 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createDeleteNoopTest: TestDefinition = {
|
||||
description:
|
||||
"A client creates a file, updates it multiple times, then deletes it, all while " +
|
||||
"offline. After syncing, neither client should have the file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "temp.md", content: "version 1" },
|
||||
{ type: "update", client: 0, path: "temp.md", content: "version 2" },
|
||||
{ type: "update", client: 0, path: "temp.md", content: "version 3" },
|
||||
{ type: "delete", client: 0, path: "temp.md" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("temp.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createDuringReconciliationTest: TestDefinition = {
|
||||
description:
|
||||
"Client creates two files while offline, reconnects, then immediately " +
|
||||
"creates a third file. All three files should sync to the other client.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "offline A"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "offline B"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "C.md",
|
||||
content: "post-reconnect C"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("A.md", "offline A")
|
||||
.assertContent("B.md", "offline B")
|
||||
.assertContent("C.md", "post-reconnect C");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createMergeDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"Two clients create A.md offline with different content. Both come online and " +
|
||||
"the content is merged. Then one client deletes A.md. Both clients should " +
|
||||
"converge on an empty state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "from-zero" },
|
||||
{ type: "create", client: 1, path: "A.md", content: "from-one" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("A.md", "from-zero", "from-one");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0).assertFileNotExists("A.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createMergePreservesRenamedUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create the same file, which gets merged. One client goes " +
|
||||
"offline, renames the file, updates it, and creates a new file at the " +
|
||||
"original path. After reconnecting, the updated content must be preserved.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "alpha" },
|
||||
{ type: "create", client: 1, path: "doc.md", content: "beta" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertContains("doc.md", "alpha", "beta");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "moved.md",
|
||||
content: "alpha beta extra-update"
|
||||
},
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "new-content"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertContent("moved.md", "alpha beta extra-update")
|
||||
.assertContent("doc.md", "new-content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createRenameCreateSamePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client creates A.md, renames to B.md, creates new A.md, renames " +
|
||||
"to C.md, creates yet another A.md. All three files should exist " +
|
||||
"as separate documents on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "first file" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{ type: "create", client: 0, path: "A.md", content: "second file" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
|
||||
{ type: "create", client: 0, path: "A.md", content: "third file" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("B.md", "first file")
|
||||
.assertContent("C.md", "second file")
|
||||
.assertContent("A.md", "third file");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createRenameResponseSkipsFileTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file online then immediately renames it. " +
|
||||
"Client 1 must receive the file content at the renamed path.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "the-content"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertAnyFileContains("the-content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const createUpdateCoalesceServerPauseTest: TestDefinition = {
|
||||
description:
|
||||
"Client creates a file and immediately updates it while the server is " +
|
||||
"paused. When the server resumes, both clients should have the final " +
|
||||
"updated content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "create", client: 0, path: "doc.md", content: "initial" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "final version" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContent("doc.md", "final version");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteByOtherClientThenRecreateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 deletes a file and the delete propagates. Then client 0 " +
|
||||
"creates a new file at the same path. Both clients must have the file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md");
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "recreated by client 0"
|
||||
},
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "recreated by client 0");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteDuringPendingCreateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file while the server is paused, then deletes it before the server resumes. " +
|
||||
"After resume, the file should end up deleted on both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "ephemeral.md",
|
||||
content: "this will be deleted"
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "ephemeral.md" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0).assertFileNotExists("ephemeral.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " +
|
||||
"After client 0 reconnects, both clients must converge with client 0's recreated content preserved.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "recreated by client 0"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("A.md").assertContains("A.md", "recreated");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " +
|
||||
"Both clients should converge with content from both sides merged.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content here"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "brand new content"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "edit from client 1"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"A.md",
|
||||
"brand new",
|
||||
"client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreateSamePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " +
|
||||
"with different content. Both clients should converge on the new content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "version 1" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "version 1");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "create", client: 0, path: "A.md", content: "version 2" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "version 2");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRecreatedPendingCreateWithStaleDeletingRecordTest: TestDefinition =
|
||||
{
|
||||
description:
|
||||
"A local delete for a recreated pending create must target the " +
|
||||
"new pending create, not an older same-path record whose server " +
|
||||
"delete has been acked but whose WebSocket delete receipt is " +
|
||||
"still paused.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:first"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "binary-14.bin",
|
||||
content: "BINARY:second"
|
||||
},
|
||||
{ type: "sleep", ms: 100 },
|
||||
{ type: "delete", client: 0, path: "binary-14.bin" },
|
||||
{ type: "resume-server" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const deleteRenameConflictTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md while client 1 renames A.md to C.md offline. " +
|
||||
"After client 1 reconnects, both clients should converge to the same state.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "content-b" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("A.md").assertFileExists("B.md");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("B.md", "content-b");
|
||||
s.assertFileNotExists("A.md");
|
||||
s.ifFileExists("C.md", (inner) =>
|
||||
inner.assertContent("C.md", "content-a")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a new file at path B.md while client 1 renames " +
|
||||
"A.md to B.md. The remote download of B.md displaces client 1's " +
|
||||
"renamed file. The displaced document must not be permanently " +
|
||||
"marked as recently deleted, so it can still be synced.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content of A" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "B.md", content: "content of B" },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(2)
|
||||
.assertContent("B.md", "content of B")
|
||||
.assertContent("C.md", "content of A");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const doubleOfflineCycleTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 goes through three offline-edit-reconnect cycles. " +
|
||||
"Each offline edit must propagate to client 1 after reconnection.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "initial"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "initial");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "first edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "first edit");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "second edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "second edit");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "third edit"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "third edit");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file, then the server is paused mid-response. " +
|
||||
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "important data"
|
||||
},
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "important data");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const interruptedDeleteRetryTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file, then the server is paused. " +
|
||||
"After the server resumes, both clients should have zero files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create doc.md with different content while offline. " +
|
||||
"Client 0 also edits the file before syncing. After both connect, " +
|
||||
"the merged result should contain content from both clients.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "from-client-0"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "local-edit-during-create"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"doc.md",
|
||||
"from-client-1",
|
||||
"local-edit-during-create"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localRenameSurvivesRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Drain processes a RemoteChange (remote rename for doc D) while a " +
|
||||
"LocalUpdate (user rename of D) is also queued behind it. " +
|
||||
"`processRemoteUpdate` moves the disk file and, because there is a " +
|
||||
"pending LocalUpdate, takes the else branch — but its setDocument " +
|
||||
"uses the stale `record.path` (= the user-rename target) instead of " +
|
||||
"the actualPath the file just moved to. The queued LocalUpdate then " +
|
||||
"reads from `record.path`, throws FileNotFoundError, and is " +
|
||||
"silently dropped. Setup pins the queue order: a sentinel " +
|
||||
"LocalUpdate keeps drain busy on a SIGSTOPped HTTP roundtrip while " +
|
||||
"we resume client 0's WebSocket (enqueues RemoteChange) and then " +
|
||||
"user-rename D (enqueues LocalUpdate after the RemoteChange). On " +
|
||||
"server resume the drain pops the sentinel, then RemoteChange, then " +
|
||||
"LocalUpdate — exactly the order that triggers the bug.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "create", client: 0, path: "sentinel.md", content: "s\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WebSocket so the upcoming remote rename buffers.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
// Server applies remote rename of doc.md -> remote.md. Broadcast
|
||||
// is buffered on client 0's WebSocket.
|
||||
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "remote.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server BEFORE arming the sentinel, so the sentinel's
|
||||
// HTTP request will buffer at the kernel and keep drain occupied.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Sentinel: a LocalUpdate on a *different* doc that drain pops
|
||||
// first. Its HTTP roundtrip stalls on SIGSTOP, freezing drain
|
||||
// until we resume the server. While drain is frozen we can grow
|
||||
// the queue with additional events whose order we control.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "sentinel.md",
|
||||
content: "s\nedit\n"
|
||||
},
|
||||
|
||||
// Resume the WebSocket — buffered remote rename enqueues as a
|
||||
// RemoteChange. Drain is still stuck on the sentinel HTTP.
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
// User renames doc.md -> local.md on client 0. queue.enqueue
|
||||
// mutates the doc's record.path to "local.md" and pushes a
|
||||
// LocalUpdate(rename) onto the tail of the queue. Queue is now
|
||||
// [sentinel-update (in-flight), RemoteChange, LocalUpdate-rename].
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "local.md" },
|
||||
|
||||
// Resume the server. Drain pops sentinel-update (succeeds), then
|
||||
// RemoteChange. Pre-fix: processRemoteUpdate moves disk
|
||||
// local.md -> remote.md, takes the else branch, and
|
||||
// setDocument(record.path = "local.md", …) leaves record.path
|
||||
// stale. Drain pops the LocalUpdate-rename and reads from the
|
||||
// stale record.path, hits FileNotFoundError, silent skip.
|
||||
// Post-fix: when a local event is pending, we re-queue the
|
||||
// remote update without touching disk or record, so the local
|
||||
// rename drains first and both ends converge.
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(2);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const localUpdateSurvivesRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 has a local content edit pending while a remote rename for " +
|
||||
"the same doc arrives over the WebSocket. The remote rename's internal " +
|
||||
"move relocates the disk file from the old path (where the user wrote) " +
|
||||
"to the new server path. Previously, the queued LocalUpdate's " +
|
||||
"`event.path` was left pointing at the now-vacated old path, so " +
|
||||
"`skipIfOversized`'s `getFileSize(event.path)` threw " +
|
||||
"`FileNotFoundError`, which `processEvent`'s catch silently swallowed " +
|
||||
"as 'Skipping sync event 'local-update' because the file no longer " +
|
||||
"exists' — and the user's edit was lost. The fix routes the size " +
|
||||
"check through `tracked.path` (the doc's current disk path), " +
|
||||
"matching the path `processLocalUpdate` itself reads from.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "v1\n" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Pause client 0's WebSocket so the upcoming remote rename buffers
|
||||
// there until we've already enqueued client 0's local content
|
||||
// edit. This guarantees the LocalUpdate sits in client 0's queue
|
||||
// when the rename's RemoteChange drains.
|
||||
{ type: "pause-websocket", client: 0 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "doc.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 still believes the file is at `doc.md` (its WebSocket is
|
||||
// paused, so the rename hasn't reached it). The user edits content
|
||||
// at `doc.md`. This pushes a LocalUpdate(D, path=doc.md,
|
||||
// originalPath=doc.md, isUserRename=false) into client 0's queue.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "v1\nclient 0 edit\n"
|
||||
},
|
||||
|
||||
// Resume the WebSocket. The buffered remote rename (server-broadcast)
|
||||
// drains. `processRemoteUpdate` does an internal `move(doc.md,
|
||||
// renamed.md)` and, because there's a pending LocalUpdate for D,
|
||||
// takes the else branch (re-enqueue v_K, setDocument(renamed.md, …)).
|
||||
// Then drain reaches the LocalUpdate. Pre-fix: skipped silently.
|
||||
// Post-fix: PUTs the user's content to the doc (at its new path,
|
||||
// since this is a content-only edit, not a user rename).
|
||||
{ type: "resume-websocket", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertContent("renamed.md", "v1\nclient 0 edit\n");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcCrossCreateRenameSameTargetTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " +
|
||||
"X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " +
|
||||
"with both contents preserved via path deconfliction.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "content-x" },
|
||||
{ type: "create", client: 1, path: "Y.md", content: "content-y" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("X.md").assertFileExists("Y.md");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(2)
|
||||
.assertFileNotExists("X.md")
|
||||
.assertFileNotExists("Y.md")
|
||||
.assertFileExists("Z.md")
|
||||
.assertAnyFileContains("content-x", "content-y");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " +
|
||||
"A.md and syncs. Client 1 (offline) renames A.md to B.md. Client 1 reconnects. " +
|
||||
"Both must converge. C.md (unrelated) must be unaffected.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "unrelated" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("C.md", "unrelated").assertFileNotExists(
|
||||
"A.md"
|
||||
);
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "original")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " +
|
||||
"renames one of the deleted files. Both must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "file-1.md", content: "content-1" },
|
||||
{ type: "create", client: 0, path: "file-2.md", content: "content-2" },
|
||||
{ type: "create", client: 0, path: "file-3.md", content: "content-3" },
|
||||
{ type: "create", client: 0, path: "file-4.md", content: "content-4" },
|
||||
{ type: "create", client: 0, path: "file-5.md", content: "content-5" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 1, path: "file-2.md" },
|
||||
{ type: "delete", client: 1, path: "file-4.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "file-2.md",
|
||||
newPath: "renamed.md"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("file-1.md")
|
||||
.assertFileExists("file-3.md")
|
||||
.assertFileExists("file-5.md")
|
||||
.assertFileNotExists("file-2.md")
|
||||
.assertFileNotExists("file-4.md");
|
||||
s.ifFileExists("renamed.md", (inner) =>
|
||||
inner.assertContent("renamed.md", "content-2")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " +
|
||||
"updates A.md. All three converge with updated content at B.md.",
|
||||
clients: 3,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 2 },
|
||||
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 2,
|
||||
path: "A.md",
|
||||
content: "updated-by-client-2"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 2 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertContains("B.md", "updated-by-client-2");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const mergingUpdateResponseSurvivesUserRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 sends a content update with a stale `parent_version_id` " +
|
||||
"(its WebSocket is paused, so it hasn't seen Client 0's intervening " +
|
||||
"edit). The server merges and replies with `MergingUpdate` carrying " +
|
||||
"the merged text. Before the response lands, the user renames the " +
|
||||
"doc on Client 1, vacating the disk path the in-flight " +
|
||||
"`processLocalUpdate` captured. Pre-fix: " +
|
||||
"`handleMaybeMergingResponse`'s `operations.write(diskPath, …)` " +
|
||||
"hits the `we wont recreate it` early-return inside `write`, " +
|
||||
"silently dropping the server-merged content — Client 0's edit is " +
|
||||
"lost on Client 1's disk, and Client 1's next local-update PUT " +
|
||||
"(rebased on the now-untracked merged version) deletes Client 0's " +
|
||||
"edit on the server too. Post-fix: the response is written to the " +
|
||||
"doc's current tracked disk path, preserving both edits.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "create", client: 0, path: "doc.md", content: "0\n" },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Stop Client 1 from seeing Client 0's next edit, so its next
|
||||
// outbound PUT carries a stale `parent_version_id` and the server
|
||||
// is forced to merge.
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
|
||||
// Server now holds v_b = "0\nA\n". Client 1's tracked parent
|
||||
// version stays at v_a = "0\n".
|
||||
{ type: "update", client: 0, path: "doc.md", content: "0\nA\n" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
// Pause the server. Subsequent HTTP PUTs from Client 1 buffer at
|
||||
// the OS layer until resume. This guarantees the merge response
|
||||
// for Client 1's update is still in flight when the rename below
|
||||
// mutates `queue.documents`.
|
||||
{ type: "pause-server" },
|
||||
|
||||
// Client 1 edits doc.md with "B". The drain pops the LocalUpdate,
|
||||
// captures `diskPath = "doc.md"`, reads the file, and sends the
|
||||
// HTTP PUT — which buffers because the server is SIGSTOPped.
|
||||
{ type: "update", client: 1, path: "doc.md", content: "0\nB\n" },
|
||||
|
||||
// User renames the file while the previous PUT is still in flight.
|
||||
// `queue.enqueue`'s rename branch updates `documents` to point at
|
||||
// `renamed.md` synchronously, but `processLocalUpdate`'s captured
|
||||
// `diskPath` ("doc.md") is a local — it can't be retargeted.
|
||||
{ type: "rename", client: 1, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
|
||||
// Resume the server. It reconciles parent=v_a, latest=v_b,
|
||||
// new="0\nB\n" → v_c with both edits, replies `MergingUpdate`.
|
||||
// Pre-fix: write("doc.md", …) sees no file at that path
|
||||
// (renamed.md now holds the data) and bails out without ever
|
||||
// writing the merged bytes. Post-fix: the merged bytes land at
|
||||
// the tracked path (renamed.md).
|
||||
{ type: "resume-server" },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(1);
|
||||
state.assertFileExists("renamed.md");
|
||||
state.assertFileNotExists("doc.md");
|
||||
// Both edits survive: Client 0's "A" and Client 1's "B".
|
||||
// The reconcile may interleave them either way; assert
|
||||
// both tokens are present in the converged content.
|
||||
state.assertContains("renamed.md", "A", "B");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveAndConcurrentRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames A.md to B.md offline while client 1 updates A.md. " +
|
||||
"After client 0 reconnects, both should have B.md with client 1's updated content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertContains("B.md", "updated by client 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveChainThreeFilesTest: TestDefinition = {
|
||||
description:
|
||||
"Three files have their contents rotated (A gets C's content, B gets A's, C gets B's) " +
|
||||
"while offline. After reconnecting, both clients should converge with the rotated contents.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
|
||||
{ type: "create", client: 0, path: "A.md", content: "was A" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "was B" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "was C" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
{ type: "delete", client: 0, path: "C.md" },
|
||||
|
||||
{ type: "create", client: 0, path: "A.md", content: "was C" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "was A" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "was B" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(3)
|
||||
.assertContent("A.md", "was C")
|
||||
.assertContent("B.md", "was A")
|
||||
.assertContent("C.md", "was B");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveIdenticalContentAmbiguityTest: TestDefinition = {
|
||||
description:
|
||||
"Two files with identical content exist. One is deleted and the other renamed " +
|
||||
"while offline. The system should still converge correctly despite the ambiguity.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "identical content"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "identical content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertContent("C.md", "identical content");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const movePreservesRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames a file offline while client 1 edits it offline. " +
|
||||
"After both reconnect, the renamed file should contain client 1's edit.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "line 1\nline 2"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "line 1\nclient 1 edit\nline 2"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1);
|
||||
const [content] = Array.from(s.files.values());
|
||||
if (!content.includes("client 1 edit")) {
|
||||
throw new Error(
|
||||
`Expected merged content to include "client 1 edit", got: "${content}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveRemoteUpdateRevertsRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " +
|
||||
"Both clients should converge with client 1's updated content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "doc.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent(
|
||||
"renamed.md",
|
||||
"updated by client 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const moveThenDeleteStalePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames A.md to B.md and immediately deletes B.md. " +
|
||||
"Both clients should end up with zero files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0)
|
||||
.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const multiFileOperationsTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
|
||||
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
{ type: "create", client: 0, path: "B.md", content: "content-b" },
|
||||
{ type: "create", client: 0, path: "C.md", content: "content-c" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "updated by client 1"
|
||||
},
|
||||
{ type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContains("B.md", "updated")
|
||||
.assertFileExists("C.md")
|
||||
.assertFileNotExists("A.md");
|
||||
s.ifFileExists("D.md", (inner) =>
|
||||
inner.assertContent("D.md", "content-a")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineConcurrentRenamesTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md and syncs to both clients. Both clients go offline. " +
|
||||
"Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " +
|
||||
"Both reconnect. The system must converge -- both clients should " +
|
||||
"agree on the final state and the content must not be lost.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "shared-content" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "shared-content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "C.md"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertAnyFileContains("shared-content");
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "shared-content")
|
||||
);
|
||||
s.ifFileExists("C.md", (inner) =>
|
||||
inner.assertContent("C.md", "shared-content")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineCreateSamePathMergeableTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create a file at the same path while offline with different text content. " +
|
||||
"After both sync, both clients must converge to a merged result containing both contributions.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "notes.md",
|
||||
content: "alpha wrote this line"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 1,
|
||||
path: "notes.md",
|
||||
content: "beta wrote this different line"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1)
|
||||
.assertFileExists("notes.md")
|
||||
.assertContains(
|
||||
"notes.md",
|
||||
"alpha wrote this line",
|
||||
"beta wrote this different line"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " +
|
||||
"After client 0 reconnects, both clients must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "A_renamed.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").assertFileNotExists(
|
||||
"A_renamed.md"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineDeleteVsRemoteUpdateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "original content"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original content");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "A.md",
|
||||
content: "important update by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineEditRemoteRenameTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 edits A.md offline while client 1 renames A.md to B.md. " +
|
||||
"After client 0 reconnects, the edit must appear in B.md and A.md must not exist.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "edited by client 0"
|
||||
},
|
||||
|
||||
{
|
||||
type: "rename",
|
||||
client: 1,
|
||||
oldPath: "A.md",
|
||||
newPath: "B.md"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertContains("B.md", "edited by client 0");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||
description:
|
||||
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content A"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "content B"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 0, path: "A.md" },
|
||||
|
||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "C.md",
|
||||
content: "content A"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("B.md")
|
||||
.assertContent("C.md", "content A")
|
||||
.assertFileCount(1);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMixedOperationsTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " +
|
||||
"deletes file 1, renames file 2 to a new name, and edits file 3. " +
|
||||
"When Client 0 reconnects, all three operations should propagate to Client 1.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "file1.md", content: "content-1" },
|
||||
{ type: "create", client: 0, path: "file2.md", content: "content-2" },
|
||||
{ type: "create", client: 0, path: "file3.md", content: "content-3" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("file1.md", "content-1")
|
||||
.assertContent("file2.md", "content-2")
|
||||
.assertContent("file3.md", "content-3");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "delete", client: 0, path: "file1.md" },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "file2.md",
|
||||
newPath: "moved.md"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "file3.md",
|
||||
content: "updated-content-3"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("file1.md")
|
||||
.assertFileNotExists("file2.md")
|
||||
.assertContent("moved.md", "content-2")
|
||||
.assertContent("file3.md", "updated-content-3")
|
||||
.assertFileCount(2);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMoveThenRemoteDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames A.md to B.md offline while client 1 deletes A.md. " +
|
||||
"Both clients must converge to having no files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "content to delete"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
|
||||
{ type: "delete", client: 1, path: "A.md" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineMultipleEditsTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file and syncs. Client 0 goes offline, edits the file " +
|
||||
"5 times with different content. When Client 0 reconnects, both clients " +
|
||||
"must converge to the final version.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("doc.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edit-1" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edit-2" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edit-3" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edit-4" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "edit-5-final" },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "edit-5-final");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineRenameAndEditTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " +
|
||||
"to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " +
|
||||
"should both propagate to Client 1.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "edited after rename"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertContent("B.md", "edited after rename");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " +
|
||||
"(same document). When Client 0 reconnects, the rename and update " +
|
||||
"should merge. Y.md should exist with Client 1's content.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "original" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("X.md", "original");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "X.md",
|
||||
newPath: "Y.md"
|
||||
},
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "X.md",
|
||||
content: "updated-by-client-1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
"Y.md",
|
||||
"updated-by-client-1"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 goes offline, updates A.md and B.md, then deletes B.md. " +
|
||||
"Client 1 updates B.md while Client 0 is offline. When Client 0 " +
|
||||
"reconnects, A.md should have the update and B.md should be " +
|
||||
"consistently resolved (delete wins).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "A original"
|
||||
},
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "B original"
|
||||
},
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "A original").assertContent(
|
||||
"B.md",
|
||||
"B original"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "A.md",
|
||||
content: "A updated by client 0"
|
||||
},
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "B.md",
|
||||
content: "B updated by client 0"
|
||||
},
|
||||
|
||||
{ type: "delete", client: 0, path: "B.md" },
|
||||
|
||||
{
|
||||
type: "update",
|
||||
client: 1,
|
||||
path: "B.md",
|
||||
content: "B updated by client 1"
|
||||
},
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent(
|
||||
"A.md",
|
||||
"A updated by client 0"
|
||||
).assertFileNotExists("B.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create a file at the same path while online. " +
|
||||
"One client's create gets deconflicted by the server. " +
|
||||
"Both files must exist on both clients after convergence.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "pause-websocket", client: 1 },
|
||||
{ type: "create", client: 0, path: "A.md", content: " from-client-0 " },
|
||||
{ type: "update", client: 0, path: "A.md", content: " updated-by-0 " },
|
||||
{ type: "sync" },
|
||||
|
||||
{ type: "create", client: 1, path: "A.md", content: " from-client-1 " },
|
||||
{ type: "resume-websocket", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state
|
||||
.assertFileCount(1)
|
||||
.assertContains("A.md", "updated-by-0", "from-client-1 ");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a binary file and renames it while offline, then reconnects and immediately deletes it. " +
|
||||
"Both clients must converge to zero files.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "data.bin",
|
||||
content: "BINARY:offline-content"
|
||||
},
|
||||
{
|
||||
type: "rename",
|
||||
client: 0,
|
||||
oldPath: "data.bin",
|
||||
newPath: "moved.bin"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "delete", client: 0, path: "moved.bin" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (state: AssertableState): void => {
|
||||
state.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue