diff --git a/.editorconfig b/.editorconfig index 9c63a68d..ade62e59 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,8 @@ trim_trailing_whitespace = true charset = utf-8 indent_style = space indent_size = 4 +tab_width = 4 -[*.{yml,yaml}] +[*.{yml,yaml,md}] indent_size = 2 +tab_width = 2 diff --git a/.github/workflows/check.yml b/.forgejo/workflows/check.yml similarity index 50% rename from .github/workflows/check.yml rename to .forgejo/workflows/check.yml index e2421e27..40e01dea 100644 --- a/.github/workflows/check.yml +++ b/.forgejo/workflows/check.yml @@ -5,6 +5,7 @@ on: branches: ["main"] pull_request: branches: ["main"] + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -12,29 +13,23 @@ env: jobs: build: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 + uses: actions/setup-node@v4 with: - node-version: "22.x" - check-latest: true - + node-version: "25.x" + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: - toolchain: "1.89.0" + toolchain: "1.92.0" components: clippy, rustfmt - - name: Setup rust - run: | - cargo install sqlx-cli cargo-machete - 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 - - name: Lint & test run: scripts/check.sh diff --git a/.forgejo/workflows/deploy-docs.yml b/.forgejo/workflows/deploy-docs.yml new file mode 100644 index 00000000..c49d0379 --- /dev/null +++ b/.forgejo/workflows/deploy-docs.yml @@ -0,0 +1,38 @@ +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 diff --git a/.forgejo/workflows/e2e.yml b/.forgejo/workflows/e2e.yml new file mode 100644 index 00000000..eb8d1e54 --- /dev/null +++ b/.forgejo/workflows/e2e.yml @@ -0,0 +1,71 @@ +name: E2E tests + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "0 * * * *" + workflow_dispatch: + +concurrency: + group: e2e-tests + cancel-in-progress: false + +env: + RUSTFLAGS: "-Dwarnings" + +jobs: + build: + 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: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.92.0" + components: clippy, rustfmt + + - name: Setup rust + run: | + which sqlx || cargo install sqlx-cli + 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 + + - name: E2E tests + run: | + cd sync-server + cargo run config-e2e.yml --color never & + SERVER_PID=$! + cd .. + + scripts/e2e.sh 8 + EXIT_CODE=$? + + kill $SERVER_PID 2>/dev/null || true + wait $SERVER_PID 2>/dev/null || true + + exit $EXIT_CODE + + - name: Upload e2e logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: logs/ + retention-days: 30 + + - name: Cleanup + if: always() + run: scripts/clean-up.sh diff --git a/.forgejo/workflows/publish-cli-docker.yml b/.forgejo/workflows/publish-cli-docker.yml new file mode 100644 index 00000000..265283ab --- /dev/null +++ b/.forgejo/workflows/publish-cli-docker.yml @@ -0,0 +1,51 @@ +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 diff --git a/.forgejo/workflows/publish-plugin.yml b/.forgejo/workflows/publish-plugin.yml new file mode 100644 index 00000000..25a652aa --- /dev/null +++ b/.forgejo/workflows/publish-plugin.yml @@ -0,0 +1,71 @@ +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 diff --git a/.forgejo/workflows/publish-server-docker.yml b/.forgejo/workflows/publish-server-docker.yml new file mode 100644 index 00000000..23852e56 --- /dev/null +++ b/.forgejo/workflows/publish-server-docker.yml @@ -0,0 +1,51 @@ +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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b445fda5..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -# 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"] - 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" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index c540f1e4..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: E2E tests - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-Dwarnings" - -jobs: - build: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "22.x" - check-latest: true - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.89.0" - components: clippy, rustfmt - - - name: Setup rust - run: | - cargo install sqlx-cli - 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 - - - name: E2E tests - run: | - cd sync-server - cargo run config-e2e.yml --color never & - cd .. - - scripts/e2e.sh 32 diff --git a/.github/workflows/publish-cli-docker.yml b/.github/workflows/publish-cli-docker.yml deleted file mode 100644 index 73ef1b12..00000000 --- a/.github/workflows/publish-cli-docker.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Publish CLI - -on: - push: - 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 - - - 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} diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml deleted file mode 100644 index ed223780..00000000 --- a/.github/workflows/publish-plugin.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Publish Obsidian plugin - -on: - push: - tags: ["*"] - -env: - CARGO_TERM_COLOR: always - -jobs: - publish-plugin: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - - - 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 \ - * diff --git a/.github/workflows/publish-server-docker.yml b/.github/workflows/publish-server-docker.yml deleted file mode 100644 index f9fee79b..00000000 --- a/.github/workflows/publish-server-docker.yml +++ /dev/null @@ -1,90 +0,0 @@ -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 / - 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 - - # 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} diff --git a/.gitignore b/.gitignore index ef64105e..967b2b65 100644 --- a/.gitignore +++ b/.gitignore @@ -4,18 +4,21 @@ node_modules # Exclude macOS Finder (System Explorer) View States .DS_Store - - # 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 diff --git a/.vscode/settings.json b/.vscode/settings.json index 88d395f5..98187650 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,6 @@ "**/dist": true, "**/node_modules": true, "**/.sqlx": true, - "**/target": true, - }, + "**/target": true + } } diff --git a/CLAUDE.md b/CLAUDE.md index e05e784a..39161e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,98 +2,154 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Overview +## Project shape -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. +VaultLink is a self-hosted Obsidian file-sync system. Two halves of one repo: -## Architecture +- `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. -### Core Components +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. -- **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 +### Frontend workspaces -### Key Technologies +- `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. -- **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 +## Common commands -## Development Commands +Pre-push hygiene (formats, lints, runs tests, requires clean git state): -### Server Development -```bash -cd sync-server -cargo run config-e2e.yml # Start development server -cargo test --verbose # Run Rust tests -cargo clippy --all-targets --all-features # Lint Rust code -cargo fmt --all -- --check # Check Rust formatting +```sh +scripts/check.sh --fix ``` -### Frontend Development -```bash +Run the fuzz E2E (N parallel processes): + +```sh +scripts/e2e.sh 12 +# Logs land in logs/log_.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 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 +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 ``` -### Database Setup (Development) -```bash +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 +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 +``` + +Frontend dev (sync-client + obsidian-plugin watch in parallel): + +```sh +cd frontend && npm install && npm run dev +``` + +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 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 ``` -### Scripts -- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend) -- `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 +New migrations: `sqlx migrate add --source src/app_state/database/migrations `. -## Code Structure +## Sync engine architecture -### Workspace Configuration -The frontend uses npm workspaces with three packages: -- `sync-client`: Core synchronization logic -- `obsidian-plugin`: Obsidian-specific integration -- `test-client`: Testing utilities +Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry). -### Type Generation -Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages. +The engine is **two independent loops with separate invariants**: -### 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 +- **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. -## Testing +**`SyncEventQueue`** (`sync-event-queue.ts`) holds: -### Running Tests -- Server: `cargo test --verbose` -- Frontend: `npm run test` (runs Jest across all workspaces) -- E2E: `scripts/e2e.sh` +- `byDocId: Map` — primary record store. +- `byLocalPath: Map` — derived index for path lookups, maintained at every mutation point. +- `events: SyncEvent[]` — pending wire ops in FIFO drain order. -### Test Structure -- Rust: Unit tests alongside source files -- TypeScript: `.test.ts` files using Jest -- E2E: Uses test-client to simulate multiple concurrent users +```ts +DocumentRecord = { + documentId, + parentVersionId, + remoteHash?, + remoteRelativePath, + localPath: RelativePath | undefined +} +``` -## Code Style +`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`). -### Rust -- Uses extensive Clippy lints (see Cargo.toml) -- Follows pedantic linting rules -- Forbids unsafe code -- Uses cargo fmt with default settings +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`. -### TypeScript -- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings -- ESLint with unused imports plugin -- Consistent across all three frontend packages \ No newline at end of file +**Pending creates** use a `Promise` 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. + +**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. + +**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. + +## Edge-case patterns the sync engine has to survive + +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: + +**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. + +**`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. + +**`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. + +**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-.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`. diff --git a/README.md b/README.md index 77f1f9ad..74c6ee97 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,24 @@ [![Check](https://github.com/schmelczer/vault-link/actions/workflows/check.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/check.yml) [![E2E tests](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/e2e.yml) -[![Publish server Docker image](https://github.com/schmelczer/vault-link/actions/workflows/publish-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-docker.yml) +[![Publish server Docker image](https://github.com/schmelczer/vault-link/actions/workflows/publish-server-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-server-docker.yml) +[![Publish CLI](https://github.com/schmelczer/vault-link/actions/workflows/publish-cli-docker.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-cli-docker.yml) [![Publish Obsidian plugin](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml/badge.svg)](https://github.com/schmelczer/vault-link/actions/workflows/publish-plugin.yml) ## Develop -### Install [nvm](https://github.com/nvm-sh/nvm) +### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm) - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` -- `nvm install 22` -- `nvm use 22` -- Optionally set the system-wide default: `nvm alias default 22` +- `nvm install 25` +- `nvm use 25` +- Optionally, set the system-wide default: `nvm alias default 25` ### Set up Rust - Install [`rustup`](https://rustup.rs): `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` - Install [`wasm-pack`](https://rustwasm.github.io/wasm-pack/installer): `curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh` -- `cargo install cargo-insta sqlx-cli cargo-edit` +- `cargo install cargo-insta sqlx-cli` ### Install Obsidian on Linux @@ -34,7 +35,7 @@ flatpak run md.obsidian.Obsidian Start the server: ```sh -cargo install sqlx-cli cargo-machete cargo-edit +cargo install sqlx-cli cd sync-server cargo run config-e2e.yml ``` @@ -68,7 +69,7 @@ scripts/bump-version.sh patch #### Run E2E tests ```sh -scripts/e2e.sh +scripts/e2e.sh 8 ``` And to clean up the logs & database files, run `scripts/clean-up.sh` diff --git a/docs/.cspell.json b/docs/.cspell.json new file mode 100644 index 00000000..1177e1e1 --- /dev/null +++ b/docs/.cspell.json @@ -0,0 +1,87 @@ +{ + "version": "0.2", + "language": "en-GB", + "dictionaries": ["en-gb"], + "ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"], + "words": [ + "VaultLink", + "Obsidian", + "WebSocket", + "SQLite", + "codebase", + "CRDT", + "CRDTs", + "YAML", + "nginx", + "Caddy", + "Traefik", + "systemd", + "localhost", + "vaultlink", + "Axum", + "Tokio", + "SQLx", + "reconcile", + "postgresql", + "VitePress", + "markdownlint", + "filesystem", + "backend", + "frontend", + "macOS", + "CLI", + "API", + "JSON", + "HTTP", + "HTTPS", + "SSL", + "TLS", + "WSS", + "TCP", + "VPS", + "Docker", + "Github", + "Dockerfile", + "dockerignore", + "Rustup", + "PostgreSQL", + "UUID", + "CORS", + "HSTS", + "CI", + "CD", + "OpenSSL", + "README", + "config", + "submodule", + "repo", + "autocomplete", + "autoformat", + "dedupe", + "diff", + "grep", + "stdout", + "stderr", + "chmod", + "mkdir", + "rclone", + "uuidgen", + "letsencrypt", + "fullchain", + "privkey", + "schmelczer", + "Schmelczer", + "ghcr", + "keepalive", + "healthcheck", + "writable", + "Cloudant", + "Syncthing", + "cadvisor", + "Caddyfile", + "nodelay", + "websecure", + "certresolver", + "rootfs" + ] +} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..9e337a15 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +.vitepress/dist/ +.vitepress/cache/ diff --git a/docs/.prettierignore b/docs/.prettierignore new file mode 100644 index 00000000..da61f8d6 --- /dev/null +++ b/docs/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +.vitepress/dist/ +.vitepress/cache/ +package-lock.json diff --git a/docs/.prettierrc b/docs/.prettierrc new file mode 100644 index 00000000..6ce145db --- /dev/null +++ b/docs/.prettierrc @@ -0,0 +1,19 @@ +{ + "printWidth": 120, + "tabWidth": 4, + "useTabs": false, + "semi": false, + "singleQuote": false, + "trailingComma": "none", + "endOfLine": "lf", + "proseWrap": "preserve", + "overrides": [ + { + "files": "*.md", + "options": { + "proseWrap": "preserve", + "printWidth": 120 + } + } + ] +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts new file mode 100644 index 00000000..6428314e --- /dev/null +++ b/docs/.vitepress/config.mts @@ -0,0 +1,60 @@ +import { defineConfig } from "vitepress" + +export default defineConfig({ + title: "VaultLink", + description: "Self-hosted real-time synchronisation for Obsidian", + base: "/vault-link/", + themeConfig: { + logo: "/logo.svg", + nav: [ + { text: "Home", link: "/" }, + { text: "Guide", link: "/guide/getting-started" }, + { text: "Architecture", link: "/architecture/" }, + { text: "GitHub", link: "https://github.com/schmelczer/vault-link" } + ], + sidebar: [ + { + text: "Introduction", + items: [ + { text: "What is VaultLink?", link: "/guide/what-is-vaultlink" }, + { text: "Getting Started", link: "/guide/getting-started" }, + { text: "Limitations", link: "/guide/limitations" }, + { text: "Comparison with Alternatives", link: "/guide/alternatives" } + ] + }, + { + text: "Setup", + items: [ + { text: "Server Setup", link: "/guide/server-setup" }, + { text: "Obsidian Plugin", link: "/guide/obsidian-plugin" }, + { text: "CLI Client", link: "/guide/cli-client" } + ] + }, + { + text: "Configuration", + items: [ + { text: "Server Configuration", link: "/config/server" }, + { text: "Authentication", link: "/config/authentication" }, + { text: "Advanced Options", link: "/config/advanced" } + ] + }, + { + text: "Architecture", + items: [ + { text: "Overview", link: "/architecture/" }, + { text: "Sync Algorithm", link: "/architecture/sync-algorithm" }, + { text: "Data Flow", link: "/architecture/data-flow" } + ] + } + ], + socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }], + footer: { + message: "Released under the MIT License.", + copyright: "Copyright © 2024-present Andras Schmelczer" + }, + search: { + provider: "local" + } + }, + head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]] +}) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..bfeb0ee7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,159 @@ +# VaultLink Documentation + +This directory contains the VaultLink documentation site built with [VitePress](https://vitepress.dev/). + +## Development + +### Prerequisites + +- Node.js 18+ +- npm + +### Setup + +```bash +cd docs +npm install +``` + +### Local Development + +Start the development server with hot reload: + +```bash +npm run dev +``` + +The site will be available at `http://localhost:5173/vault-link/` + +### Build + +Build the static site: + +```bash +npm run build +``` + +Output will be in `.vitepress/dist/` + +### Preview + +Preview the built site: + +```bash +npm run preview +``` + +### Format + +Format all markdown and TypeScript files: + +```bash +npm run format +``` + +Check formatting without making changes: + +```bash +npm run format:check +``` + +### Spell Check + +Check spelling (British English): + +```bash +npm run spell +``` + +The spell checker enforces British English spellings (e.g., "synchronisation", "optimise", "behaviour"). + +## Deployment + +The documentation is automatically deployed to GitHub Pages when changes are pushed to the `main` branch. + +The deployment workflow is configured in `.github/workflows/deploy-docs.yml`. + +## Structure + +``` +docs/ +├── .vitepress/ +│ └── config.ts # VitePress configuration +├── public/ # Static assets +│ └── logo.svg # VaultLink logo +├── guide/ # User guides +│ ├── what-is-vaultlink.md +│ ├── getting-started.md +│ ├── server-setup.md +│ ├── obsidian-plugin.md +│ └── cli-client.md +├── architecture/ # Architecture documentation +│ ├── index.md +│ ├── sync-algorithm.md +│ └── data-flow.md +├── config/ # Configuration reference +│ ├── server.md +│ ├── authentication.md +│ └── advanced.md +└── index.md # Home page + +``` + +## Writing Documentation + +### Language + +All documentation uses **British English**. The spell checker enforces this in CI. + +### Markdown Features + +VitePress supports: + +- GitHub Flavoured Markdown +- Custom containers (tip, warning, danger) +- Code syntax highlighting +- Mermaid diagrams +- Emoji :rocket: + +### Custom Containers + +```markdown +::: tip +This is a tip +::: + +::: warning +This is a warning +::: + +::: danger +This is a danger message +::: +``` + +### Code Blocks + +````markdown +```bash +npm install +``` + +```yaml +server: + port: 3000 +``` +```` + +## Contributing + +When adding new pages: + +1. Create the markdown file in the appropriate directory +2. Add it to the sidebar in `.vitepress/config.ts` +3. Test locally with `npm run dev` +4. Submit a pull request + +## License + +MIT - Same as VaultLink diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md new file mode 100644 index 00000000..167be524 --- /dev/null +++ b/docs/architecture/data-flow.md @@ -0,0 +1,553 @@ +# Data Flow + +How data flows through VaultLink, from client to server and back. + +## Connection Lifecycle + +### 1. Initial Connection + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + participant DB as Database + + C->>S: WebSocket connect + S->>S: Accept connection + C->>S: Auth message (token + vault) + S->>S: Validate token + S->>S: Check vault access + S-->>C: Auth success + Note over C,S: Connection established +``` + +**Steps**: + +1. Client initiates WebSocket connection to server +2. Server accepts connection +3. Client sends authentication message with token and vault name +4. Server validates token against `config.yml` +5. Server checks if user has access to requested vault +6. Server responds with success or error +7. Connection is ready for syncing + +### 2. Initial Sync + +After authentication, the client performs initial synchronisation: + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + participant DB as SQLite + + C->>C: Scan local filesystem + C->>S: Request file list + S->>DB: Query all files + DB-->>S: File metadata + S-->>C: File list with versions + + loop For each local file + C->>C: Check if file on server + alt File not on server + C->>S: Upload file + S->>DB: Store file + metadata + else File on server (different version) + C->>C: Compare versions + C->>S: Upload newer or merge + end + end + + loop For each server file + C->>C: Check if file local + alt File not local + C->>S: Download file + S->>DB: Retrieve file + DB-->>S: File content + S-->>C: File content + C->>C: Write to disk + end + end + + S-->>C: Sync complete message +``` + +**Process**: + +1. Client scans local filesystem +2. Client requests file list from server +3. Server queries database and returns metadata +4. Client uploads missing or changed local files +5. Client downloads missing files from server +6. Server sends sync complete notification + +### 3. Real-Time Synchronization + +After initial sync, changes are pushed in real-time: + +```mermaid +sequenceDiagram + participant FS as Filesystem + participant C1 as Client 1 + participant S as Server + participant DB as Database + participant C2 as Client 2 + + FS->>C1: File changed (fs.watch) + C1->>C1: Read file content + C1->>S: Upload file + S->>DB: Store new version + S->>S: Apply OT if needed + S-->>C1: Upload ACK + S->>C2: File update notification + C2->>S: Download file + S->>DB: Retrieve file + DB-->>S: File content + S-->>C2: File content + C2->>FS: Write to disk +``` + +**Flow**: + +1. Filesystem watcher detects local change +2. Client reads file content +3. Client uploads file via WebSocket +4. Server stores in database +5. Server applies operational transformation if concurrent edits +6. Server acknowledges upload to sender +7. Server broadcasts update to other clients +8. Other clients download and apply changes + +## File Operations + +### Upload + +``` +┌─────────┐ +│ Client │ +└───┬─-───┘ + │ 1. Detect file change + │ + ├─► 2. Read file content + │ + ├─► 3. Create upload message + │ { + │ type: "upload_file", + │ path: "notes/daily.md", + │ content: "...", + │ version: 42, + │ timestamp: "2024-01-01T12:00:00Z" + │ } + │ + ▼ +┌─────────┐ +│ Server │ +└───┬────-┘ + │ 4. Validate message + │ + ├─► 5. Check permissions + │ + ├─► 6. Apply OT (if conflicts) + │ + ├─► 7. Store in database + │ + ├─► 8. Update version + │ + ├─► 9. Broadcast to clients + │ + └─► 10. Send ACK to uploader +``` + +### Download + +``` +┌─────────┐ +│ Server │ +└───┬─-───┘ + │ 1. File updated by another client + │ + ├─► 2. Broadcast notification + │ { + │ type: "file_updated", + │ path: "notes/daily.md", + │ version: 43 + │ } + │ + ▼ +┌─────────┐ +│ Client │ +└───┬─-───┘ + │ 3. Receive notification + │ + ├─► 4. Request file download + │ { + │ type: "download_file", + │ path: "notes/daily.md", + │ version: 43 + │ } + │ + ▼ +┌─────────┐ +│ Server │ +└───┬─=───┘ + │ 5. Retrieve from database + │ + └─► 6. Send file content + { + type: "file_content", + path: "notes/daily.md", + content: "...", + version: 43 + } + │ + ▼ + ┌─────────┐ + │ Client │ + └───-─┬───┘ + │ 7. Write to filesystem + │ + └─► 8. Update local metadata +``` + +### Delete + +``` +┌─────────┐ +│ Client │ +└────┬────┘ + │ 1. File deleted locally + │ + ├─► 2. Send delete message + │ { + │ type: "delete_file", + │ path: "notes/old.md" + │ } + │ + ▼ +┌─────────┐ +│ Server │ +└────┬────┘ + │ 3. Mark as deleted in DB + │ (soft delete for history) + │ + ├─► 4. Broadcast deletion + │ + └─► 5. ACK to sender + │ + ▼ + ┌─────────┐ + │ Other │ + │ Clients │ + └────┬────┘ + │ 6. Delete local file + │ + └─► 7. Update metadata +``` + +## Conflict Resolution Flow + +### Concurrent Edits Scenario + +``` +Time → + +Client A Server Client B + │ │ │ + │ Edit file v10 │ │ + │ "Add line A" │ │ Edit file v10 + │ │ │ "Add line B" + │ │ │ + ├─── Upload @ t1 ─────────►│ │ + │ │◄────── Upload @ t2 ────────┤ + │ │ │ + │ │ 1. Receive both edits │ + │ │ (based on v10) │ + │ │ │ + │ │ 2. Apply first edit │ + │ │ → v11 (line A added) │ + │ │ │ + │ │ 3. Transform second edit │ + │ │ against first │ + │ │ │ + │ │ 4. Apply transformed edit │ + │ │ → v12 (both lines) │ + │ │ │ + │◄──── v12 content ────────┤ │ + │ ├───── v12 content ─────────►│ + │ │ │ + │ Apply v12 │ │ Apply v12 + │ (has both lines) │ │ (has both lines) + │ │ │ +``` + +### Conflict Resolution Steps + +1. **Detection**: Server receives two edits based on the same version +2. **Ordering**: Determine which edit to apply first (by timestamp or client ID) +3. **First edit**: Apply directly to database +4. **Transformation**: Transform second edit against first using OT +5. **Second edit**: Apply transformed edit to database +6. **Broadcast**: Send merged result to all clients +7. **Application**: Clients apply merged version locally + +## Database Schema + +### Core Tables + +```sql +-- Document metadata +CREATE TABLE documents ( + id INTEGER PRIMARY KEY, + path TEXT NOT NULL, + version INTEGER NOT NULL, + content_hash TEXT, + size INTEGER, + created_at TIMESTAMP, + updated_at TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +-- Version history +CREATE TABLE versions ( + id INTEGER PRIMARY KEY, + document_id INTEGER, + version INTEGER, + content BLOB, + created_at TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES documents(id) +); + +-- Client sync cursors +CREATE TABLE cursors ( + client_id TEXT PRIMARY KEY, + last_version INTEGER, + last_updated TIMESTAMP +); +``` + +### Queries + +**Get files since version**: + +```sql +SELECT * FROM documents +WHERE version > ? AND deleted = FALSE +ORDER BY version ASC; +``` + +**Store new version**: + +```sql +INSERT INTO versions (document_id, version, content, created_at) +VALUES (?, ?, ?, ?); + +UPDATE documents +SET version = ?, updated_at = ? +WHERE id = ?; +``` + +**Update cursor**: + +```sql +INSERT OR REPLACE INTO cursors (client_id, last_version, last_updated) +VALUES (?, ?, ?); +``` + +## Message Protocol + +### Client → Server Messages + +**Upload File**: + +```json +{ + "type": "upload_file", + "path": "notes/example.md", + "content": "File content here...", + "base_version": 10, + "timestamp": "2024-01-01T12:00:00Z" +} +``` + +**Download File**: + +```json +{ + "type": "download_file", + "path": "notes/example.md" +} +``` + +**Delete File**: + +```json +{ + "type": "delete_file", + "path": "notes/old.md" +} +``` + +**List Files**: + +```json +{ + "type": "list_files", + "since_version": 0 +} +``` + +### Server → Client Messages + +**File Updated**: + +```json +{ + "type": "file_updated", + "path": "notes/example.md", + "version": 11, + "size": 1024, + "hash": "abc123..." +} +``` + +**File Content**: + +```json +{ + "type": "file_content", + "path": "notes/example.md", + "content": "Updated content...", + "version": 11 +} +``` + +**File Deleted**: + +```json +{ + "type": "file_deleted", + "path": "notes/old.md", + "version": 12 +} +``` + +**Sync Complete**: + +```json +{ + "type": "sync_complete", + "total_files": 150, + "current_version": 200 +} +``` + +**Error**: + +```json +{ + "type": "error", + "message": "File too large", + "code": "FILE_TOO_LARGE" +} +``` + +## Error Handling + +### Client-Side Errors + +**Network failure**: + +1. Detect WebSocket disconnect +2. Queue pending operations +3. Retry connection with exponential backoff +4. Replay queued operations on reconnect + +**File read error**: + +1. Log error +2. Skip file +3. Continue with other files +4. Report to user + +**Write conflict**: + +1. Receive updated version from server +2. Apply OT merge locally +3. Overwrite local file +4. Continue syncing + +### Server-Side Errors + +**Database error**: + +1. Log error +2. Return error to client +3. Client retries operation + +**Invalid operation**: + +1. Validate message format +2. Return specific error code +3. Client handles error appropriately + +**Authentication failure**: + +1. Reject connection +2. Send auth error +3. Client prompts for new credentials + +## Performance Optimizations + +### Batching + +- Small, rapid changes are batched together +- Reduces message overhead +- Applied as single atomic update + +### Compression + +- Large files compressed before transmission +- Reduces bandwidth usage +- Transparent to application layer + +### Incremental Sync + +- Only changed portions of files sent +- Uses content-based diffing +- Significantly reduces data transfer + +### Caching + +- Server caches recent file versions +- Reduces database queries +- Improves response time + +## Monitoring Data Flow + +### Server Logs + +``` +2024-01-01 12:00:00 INFO WebSocket connection from 192.168.1.100 +2024-01-01 12:00:01 INFO User 'alice' authenticated for vault 'personal' +2024-01-01 12:00:05 INFO Upload: notes/daily.md (v10 -> v11) +2024-01-01 12:00:06 INFO Broadcast to 3 clients +2024-01-01 12:00:10 INFO Conflict resolved: notes/shared.md (v12) +``` + +### Client Logs + +``` +2024-01-01 12:00:00 INFO Connecting to ws://sync.example.com +2024-01-01 12:00:01 INFO Connected, authenticating... +2024-01-01 12:00:01 INFO Authentication successful +2024-01-01 12:00:02 INFO Starting initial sync +2024-01-01 12:00:10 INFO Sync complete: 150 files, 200 MB +2024-01-01 12:00:15 INFO Uploaded: notes/daily.md +2024-01-01 12:00:20 INFO Downloaded: notes/shared.md (merged) +``` + +## Next Steps + +- [Understand the sync algorithm →](/architecture/sync-algorithm) +- [Configure the server →](/config/server) +- [Deploy VaultLink →](/guide/getting-started) diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 00000000..bebb6c49 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,334 @@ +# Architecture Overview + +Central sync server with multiple clients. High-level architecture and design decisions. + +## System Components + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Clients │ +├─────────────────────┬───────────────────┬───────────────────┤ +│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │ +│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │ +└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘ + │ │ │ + │ WebSocket │ WebSocket │ WebSocket + │ │ │ + └────────────────────┼────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Sync Server │ + │ (Rust + Axum) │ + │ │ + │ ┌─────────────────┐ │ + │ │ WebSocket Hub │ │ + │ └────────┬────────┘ │ + │ │ │ + │ ┌────────▼────────┐ │ + │ │ Sync Engine │ │ + │ │ (OT Algorithm) │ │ + │ └────────┬────────┘ │ + │ │ │ + │ ┌────────▼────────┐ │ + │ │ SQLite Database │ │ + │ │ (Per Vault) │ │ + │ └─────────────────┘ │ + └───────────────────────┘ +``` + +## Core Components + +### Sync Server + +Central authority for synchronisation. Rust + Axum framework. + +**Responsibilities**: + +- Accept WebSocket connections from clients +- Authenticate users via token-based auth +- Store document versions in SQLite +- Coordinate real-time updates between clients +- Apply operational transformation for conflict resolution +- Manage vault access control + +**Technology**: + +- **Language**: Rust 1.92+ +- **Framework**: Axum (async web framework) +- **Database**: SQLite with SQLx +- **Protocol**: WebSockets for real-time communication +- **Sync Algorithm**: reconcile-text (operational transformation) + +### Sync Client Library + +TypeScript library with core sync logic. Used by Obsidian plugin and CLI client. + +**Responsibilities**: + +- Manage WebSocket connection to server +- Watch local filesystem for changes +- Upload and download files +- Apply remote changes locally +- Handle conflict resolution +- Maintain sync metadata + +**Technology**: + +- **Language**: TypeScript +- **Build**: Webpack +- **Protocol**: WebSocket client +- **File System**: Node.js `fs` API / Obsidian API + +### Obsidian Plugin + +Integration layer between sync client and Obsidian. + +**Responsibilities**: + +- Provide UI for configuration +- Bridge sync client with Obsidian's file system API +- Handle Obsidian lifecycle events +- Display sync status to users + +**Technology**: + +- **Platform**: Obsidian Plugin API +- **Core**: sync-client library +- **UI**: Obsidian settings UI + +### CLI Client + +Standalone executable for syncing vaults without Obsidian. + +**Responsibilities**: + +- Command-line interface +- File system access via Node.js +- Daemon mode for continuous sync +- Health check endpoint for monitoring + +**Technology**: + +- **Language**: TypeScript +- **Runtime**: Node.js +- **CLI**: Commander.js +- **Core**: sync-client library + +## Data Flow + +### Initial Connection + +1. Client connects via WebSocket to server +2. Server authenticates using provided token +3. Server verifies user has access to requested vault +4. Connection established, sync begins + +### File Upload Flow + +``` +Client Server + │ │ + │ 1. File changed locally │ + │ │ + │ 2. Read file content │ + │ │ + │ 3. WebSocket: Upload file │ + ├──────────────────────────────►│ + │ │ 4. Store in SQLite + │ │ + │ │ 5. Broadcast to other clients + │ ├───────────────────────► + │ 6. Ack upload │ + │◄──────────────────────────────┤ +``` + +### File Download Flow + +``` +Client A Server Client B + │ │ │ + │ │ 1. File uploaded │ + │ │◄────────────────────────┤ + │ │ │ + │ │ 2. Store in DB │ + │ │ │ + │ 3. Push notification │ │ + │◄────────────────────────┤ │ + │ │ │ + │ 4. Download file │ │ + ├────────────────────────►│ │ + │ │ │ + │ 5. Write locally │ │ + │ │ │ +``` + +### Conflict Resolution + +When two clients edit the same file simultaneously: + +``` +Client A Server Client B + │ │ │ + │ 1. Edit file │ │ 1. Edit same file + │ │ │ + │ 2. Upload changes │ │ 2. Upload changes + ├────────────────────────►│◄────────────────────────┤ + │ │ │ + │ │ 3. Apply OT algorithm │ + │ │ - Merge both edits │ + │ │ - Preserve all changes│ + │ │ │ + │ 4. Receive merged ver. │ 5. Receive merged ver. │ + │◄────────────────────────┤────────────────────────►│ + │ │ │ + │ 6. Apply locally │ │ 6. Apply locally +``` + +## Storage Architecture + +### Server Storage + +Each vault has its own SQLite database: + +``` +databases/ +├── vault-1.db +├── vault-2.db +└── shared-team.db +``` + +**Database Schema** (simplified): + +- **documents**: File metadata (path, size, modified time) +- **versions**: Document content with version history +- **cursors**: Client sync state + +### Client Storage + +Clients maintain sync metadata: + +``` +.vaultlink/ +├── metadata.json # Sync state +└── cache/ # Optional local cache +``` + +The `.vaultlink` directory tracks which files have been synced and their versions to enable efficient synchronisation. + +## Communication Protocol + +### WebSocket Messages + +Client-server communication uses JSON messages over WebSocket. + +**Message Types**: + +- `upload_file`: Client → Server (file upload) +- `download_file`: Client → Server (request file) +- `file_updated`: Server → Client (file changed notification) +- `file_deleted`: Server → Client (file deleted notification) +- `sync_complete`: Server → Client (initial sync finished) + +### Authentication + +Token-based authentication on connection: + +```typescript +// Client sends token on connect +{ + type: "auth", + token: "user-auth-token", + vault: "vault-name" +} + +// Server responds +{ + type: "auth_success" +} +// or +{ + type: "auth_error", + message: "Invalid token" +} +``` + +## Scalability Considerations + +### Current Architecture + +- **SQLite per vault**: Simple, performant, limited to single server +- **WebSocket connections**: Stateful, requires sticky sessions for load balancing +- **Operational transformation**: Centralized on server + +### Scaling Approaches + +**Vertical Scaling**: + +- Increase server resources (CPU, RAM, storage) +- Optimize database queries and indexing +- Tune connection limits + +**Horizontal Scaling** (future): + +- Separate vault servers (vault sharding) +- Load balancer with sticky sessions +- Shared storage layer for SQLite databases +- Consider alternative databases (PostgreSQL) for multi-server setups + +### Performance Characteristics + +- **Small vaults** (< 1000 files): Excellent performance +- **Medium vaults** (1000-10000 files): Good performance with tuning +- **Large vaults** (> 10000 files): May require optimisation +- **Concurrent users**: Tested with dozens of simultaneous clients per vault + +## Security Model + +### Authentication + +- Token-based authentication +- Tokens configured in server `config.yml` +- No password hashing (tokens are secrets) + +### Authorization + +- Per-user vault access control +- Allow-list or deny-list patterns +- Global access or vault-specific access + +### Network Security + +- WebSocket over TLS (WSS) for encrypted transport +- No built-in SSL (use reverse proxy) +- CORS configured for web clients + +### Data Security + +- No encryption at rest (use encrypted filesystems if needed) +- No end-to-end encryption (server sees all content) +- Self-hosted model: you control the data + +## Technology Choices + +**Rust**: Low latency, memory safe, excellent async with Tokio, compile-time SQL verification + +**SQLite**: No separate database server, fast for reads, single file per vault, backups are file copies + +**WebSocket**: Bidirectional push, no polling overhead, built-in browser/Node.js support + +**Operational Transformation**: Automatic conflict resolution, preserves all edits, real-time collaboration + +## Design Principles + +1. **Self-hosted first**: Users control their data and infrastructure +2. **Simplicity**: Easy to deploy and operate +3. **Real-time**: Changes appear immediately +4. **Reliability**: Handle network failures gracefully +5. **Performance**: Fast sync for typical vault sizes +6. **Privacy**: No third-party services or telemetry + +## Next Steps + +- [Learn about the sync algorithm →](/architecture/sync-algorithm) +- [Understand data flow in detail →](/architecture/data-flow) +- [Deploy the server →](/guide/server-setup) diff --git a/docs/architecture/sync-algorithm.md b/docs/architecture/sync-algorithm.md new file mode 100644 index 00000000..35e63d50 --- /dev/null +++ b/docs/architecture/sync-algorithm.md @@ -0,0 +1,438 @@ +# Sync Algorithm + +VaultLink uses operational transformation (OT) to handle concurrent edits and maintain consistency across clients. + +## Operational Transformation + +Operational transformation is a technique for managing concurrent edits to the same document. It transforms operations (edits) so they can be applied in different orders while preserving user intent. + +### Why OT? + +Traditional conflict resolution approaches: + +- **Last write wins**: Loses data, frustrating for users +- **Manual merging**: Interrupts workflow, requires user intervention +- **Version branching**: Complex, not suitable for real-time sync + +Operational transformation: + +- **Automatic**: No user intervention required +- **Preserves all edits**: No data loss +- **Real-time**: Changes appear immediately +- **Intuitive**: Behaviour matches user expectations + +## The reconcile-text Library + +VaultLink uses the [`reconcile-text`](https://crates.io/crates/reconcile-text) Rust library for operational transformation on text documents. + +### Why reconcile-text over CRDTs? + +VaultLink faces a **differential synchronisation** challenge: users edit Obsidian vaults with various editors (Obsidian desktop, Obsidian mobile, Vim, VS Code, or any text editor), often while offline. This means we only observe the **final state** of each document after editing, not the individual keystrokes or operations that produced it. + +**The fundamental problem**: + +- **CRDTs and traditional OT** require capturing every individual operation (each character insertion, deletion, cursor movement) +- **VaultLink's reality**: Users edit files with arbitrary tools, sync happens after the fact +- **What we know**: Parent version and two modified versions +- **What we don't know**: The sequence of operations that created those modifications + +**Why reconcile-text wins for this use case**: + +1. **Works with end states only**: reconcile-text performs conflict-free 3-way merging given just parent, left, and right versions—no operation history needed + +2. **Editor-agnostic**: Users can edit with any tool without requiring VaultLink-specific plugins or operation tracking + +3. **Offline-first**: Edits made while disconnected are merged cleanly when sync resumes, because we're diffing final states rather than replaying operations + +4. **No conflict markers**: Unlike Git merge, produces clean merged output without `<<<<<<<` markers that interrupt note-taking flow + +5. **Human text forgiveness**: For knowledge bases and documentation, a slightly imperfect merge (e.g., minor word order issues) is vastly preferable to manual conflict resolution + +6. **Simpler infrastructure**: No need for complex operation capture, transformation logs, or tombstone management that CRDTs require + +**The trade-off**: + +CRDTs excel when you control the entire editing infrastructure and can capture every operation. reconcile-text excels when you're synchronising independently-edited files—exactly VaultLink's scenario. The merge quality depends on Myers' diff algorithm rather than operation history, which is the correct trade-off for differential sync. + +For note-taking workflows where users value editor freedom and offline editing, this approach provides superior user experience compared to either CRDTs (which would require operation tracking) or Git-style merging (which requires manual conflict resolution). + +[Learn more about reconcile-text →](https://schmelczer.dev/reconcile) + +### How It Works + +Given three versions (parent, left, right), reconcile-text produces a merged result. + +**How reconcile-text works**: + +1. **Tokenisation**: Split text into words (using `BuiltinTokenizer::Word`) +2. **Three-way diff**: Compare parent→left and parent→right changes +3. **Merge**: Combine non-conflicting changes, prefer content preservation for conflicts +4. **Result**: Merged text with both edits applied + +**Example**: + +``` +Parent: "The quick brown fox" +User A: "The quick red fox" (changes "brown" → "red") +User B: "The very quick brown fox" (inserts "very ") + +Merged: "The very quick red fox" (both changes applied) +``` + +**Merge conditions**: Only `.md` and `.txt` files with valid UTF-8 get merged. Binary files or other extensions use last-write-wins. + +### Operation Types + +The algorithm handles these operations: + +- **Insert**: Add text at position +- **Delete**: Remove text from position +- **Retain**: Keep existing text unchanged + +### Transformation Process + +1. **Client A** makes edit and sends to server +2. **Client B** makes concurrent edit and sends to server +3. **Server** receives both edits +4. **Server** transforms operations to account for concurrent changes +5. **Server** applies merged result to database +6. **Server** sends transformed operations to both clients +7. **Clients** apply transformed operations locally + +## Sync State Management + +VaultLink maintains sync state to track which changes have been applied. + +### Version Vectors + +Each document has a version tracked by: + +- **Server version**: Incremented on each change +- **Client cursors**: Track which version each client has seen + +This enables: + +- Efficient syncing (only send changes since last sync) +- Conflict detection (concurrent edits to same version) +- Ordering of operations + +### Cursor Management + +Clients maintain a cursor position: + +```rust +struct Cursor { + vault_id: String, + client_id: String, + last_version: u64, + last_updated: DateTime, +} +``` + +On sync: + +1. Client sends cursor (last seen version) +2. Server returns all changes since that version +3. Client applies changes and updates cursor + +## Conflict Resolution Flow + +### Scenario: Concurrent Edits + +Two users edit the same paragraph simultaneously. + +**Initial state**: + +``` +Version 10: "The quick brown fox jumps over the lazy dog." +``` + +**User A's edit** (version 11): + +``` +"The quick brown fox jumps over the very lazy dog." +``` + +_Inserts "very " at position 40_ + +**User B's edit** (also from version 10): + +``` +"The quick red fox jumps over the lazy dog." +``` + +_Replaces "brown" with "red" at position 10_ + +### Server Processing + +1. **Receive User A's operation**: + - Base: version 10 + - Operation: Insert("very ", position=40) + - Apply to database → version 11 + +2. **Receive User B's operation**: + - Base: version 10 + - Operation: Replace("brown"→"red", position=10) + - **Conflict detected**: Base is version 10, but current is version 11 + +3. **Transform User B's operation**: + - Transform against User A's operation + - Adjust positions/content as needed + - Apply transformed operation → version 12 + +4. **Broadcast updates**: + - Send User A's operation to User B + - Send transformed User B's operation to User A + +### Final Result + +``` +Version 12: "The quick red fox jumps over the very lazy dog." +``` + +Both edits are preserved in the final document. + +## Edge Cases + +### 1. Delete vs Insert Conflict + +**Scenario**: User A deletes a paragraph while User B edits it. + +**Resolution**: + +- OT algorithm prioritizes preservation of content +- Insert operation is transformed to account for deletion +- Typically results in inserted content appearing nearby + +**Example**: + +``` +Base: "Line 1\nLine 2\nLine 3" + +User A: Delete Line 2 → "Line 1\nLine 3" +User B: Edit Line 2 → "Line 1\nLine 2 modified\nLine 3" + +Result: "Line 1\nLine 2 modified\nLine 3" +``` + +(Insert takes precedence, preserving user content) + +### 2. Overlapping Edits + +**Scenario**: Two users edit overlapping regions. + +**Resolution**: + +- OT splits operations into non-overlapping segments +- Applies each segment independently +- Merges results + +### 3. Delete vs Delete + +**Scenario**: Two users delete overlapping text. + +**Resolution**: + +- Deletes are merged +- Final result has the union of deleted ranges removed + +### 4. Network Partitions + +**Scenario**: Client loses connection, makes edits offline, reconnects. + +**Resolution**: + +1. Client queues edits locally +2. On reconnect, sends all queued operations +3. Server applies OT against all operations that happened during partition +4. Client receives transformed operations and applies + +## Performance Characteristics + +### Time Complexity + +- **Single operation**: O(1) for most operations +- **Transformation**: O(n) where n is operation size +- **Conflict resolution**: O(m × n) where m is number of concurrent operations + +### Space Complexity + +- **Version history**: Grows with number of changes +- **Cursors**: O(clients × vaults) +- **Active operations**: Minimal (processed in real-time) + +### Optimisation + +VaultLink optimises for: + +- Small, frequent edits (typical typing patterns) +- Text documents (not binary files) +- Real-time processing (no batching delay) + +## Limitations + +### Binary and Non-Mergeable Files + +Only **`.md`** and **`.txt`** files get automatic merging. Everything else uses last-write-wins. + +**Binary detection**: + +- Files with NUL bytes (`0x00`) +- Files failing UTF-8 validation + +Even `.md` files are treated as binary if they fail UTF-8 checks. + +**Last-write-wins behaviour**: + +``` +User A uploads image.png → Server version 1 +User B uploads image.png → Server version 2 (A's upload lost) +``` + +**Workaround**: Avoid concurrent edits to non-text files. [See all limitations →](/guide/limitations) + +### Large Documents + +Very large documents (> 1MB) may have: + +- Higher transformation costs +- Slower sync times +- Increased memory usage + +**Workaround**: Split large documents or increase timeout settings. + +### Complex Formatting + +Markdown with complex structures may occasionally produce unexpected results: + +- Nested lists +- Tables +- Code blocks + +**Workaround**: Manual cleanup if needed, or minimize concurrent edits to complex structures. + +## Consistency Guarantees + +### Strong Consistency + +VaultLink provides **strong eventual consistency**: + +- All clients eventually converge to the same state +- Operations applied in causal order +- No data loss under normal operation + +### Ordering Guarantees + +- Operations from the same client are applied in order +- Concurrent operations may be applied in any order +- Final result is independent of operation order (commutative) + +### Durability + +- Operations are written to SQLite before acknowledgment +- SQLite ACID guarantees protect against data loss +- Clients retry failed uploads + +## Comparison with Other Approaches + +### Git-style Merging + +| Aspect | Git Merge | VaultLink OT | +| -------------------------- | ------------ | ----------------------- | +| Real-time | No | Yes | +| Manual conflict resolution | Yes | No | +| Branching | Yes | No | +| Automatic merge | Limited | Always | +| Use case | Code changes | Collaborative documents | + +### CRDTs (Conflict-free Replicated Data Types) + +| Aspect | CRDTs | VaultLink (reconcile-text) | +| ----------------------------- | ------------------------------------ | ------------------------------------------------- | +| **Operation tracking** | Required (every keystroke) | Not required (end states only) | +| **Editor freedom** | Limited (must use CRDT-aware editor) | Unlimited (any text editor works) | +| **Offline editing** | Requires operation log | Works with file comparison | +| **Server required** | No | Yes | +| **Memory overhead** | Higher (tombstones, metadata) | Lower (versions only) | +| **Infrastructure complexity** | Higher | Lower | +| **Best for** | Controlled editing environments | Independent file editing (Obsidian, Vim, VS Code) | + +**Key insight**: CRDTs are superior when you can capture every operation. reconcile-text is superior when users edit files independently with arbitrary tools—exactly VaultLink's scenario. + +### Last Write Wins + +| Aspect | LWW | VaultLink OT | +| --------------- | ---- | ------------ | +| Data loss | Yes | No | +| Simplicity | High | Medium | +| User experience | Poor | Excellent | +| Performance | Best | Good | + +## Algorithm Details + +### Transformation Rules + +When transforming operation `A` against operation `B`: + +1. **Insert vs Insert**: + - If positions equal: Order by client ID + - If different positions: Adjust positions + +2. **Insert vs Delete**: + - If insert in deleted range: Shift insert position + - If insert after delete: Adjust position by deleted length + +3. **Delete vs Delete**: + - If ranges overlap: Merge delete ranges + - If ranges disjoint: Adjust positions + +4. **Retain vs Any**: + - Retain operations don't conflict + - Simply adjust positions + +### Transformation Example + +```rust +// Pseudo-code for transformation +fn transform(op_a: Operation, op_b: Operation) -> (Operation, Operation) { + match (op_a, op_b) { + (Insert(pos_a, text_a), Insert(pos_b, text_b)) => { + if pos_a < pos_b { + (op_a, Insert(pos_b + text_a.len(), text_b)) + } else if pos_a > pos_b { + (Insert(pos_a + text_b.len(), text_a), op_b) + } else { + // Same position, use client ID to break tie + if client_id_a < client_id_b { + (op_a, Insert(pos_b + text_a.len(), text_b)) + } else { + (Insert(pos_a + text_b.len(), text_a), op_b) + } + } + } + // ... other cases + } +} +``` + +## Best Practices + +### For Smooth Collaboration + +1. **Small edits**: Make small, focused changes for easier merging +2. **Coordinate major changes**: Discuss large refactors with team +3. **Monitor sync status**: Ensure changes are uploaded before signing off +4. **Test conflict resolution**: Verify behaviour matches expectations + +### For Developers + +1. **Text files preferred**: OT works best on text +2. **Limit file sizes**: Keep documents reasonably sized +3. **Binary files**: Use versioning or avoid concurrent edits +4. **Testing**: Test concurrent edit scenarios thoroughly + +## Further Reading + +- [reconcile-text library](https://crates.io/crates/reconcile-text) +- [Operational Transformation FAQ](https://en.wikipedia.org/wiki/Operational_transformation) +- [Data flow architecture →](/architecture/data-flow) diff --git a/docs/config/advanced.md b/docs/config/advanced.md new file mode 100644 index 00000000..5275be93 --- /dev/null +++ b/docs/config/advanced.md @@ -0,0 +1,603 @@ +# Advanced Configuration + +Advanced topics for optimising and customising your VaultLink deployment. + +## Database Optimisation + +### SQLite Tuning + +While VaultLink handles most SQLite configuration automatically, you can optimise for specific workloads. + +#### WAL Mode + +VaultLink uses Write-Ahead Logging (WAL) mode by default for better concurrency. + +**Benefits**: + +- Readers don't block writers +- Writers don't block readers +- Better performance for concurrent access + +**Maintenance**: + +```bash +# Checkpoint WAL to main database (run periodically) +sqlite3 databases/vault.db "PRAGMA wal_checkpoint(TRUNCATE);" +``` + +#### Database Size Management + +Over time, databases can grow with version history: + +```bash +# Check database size +du -h databases/*.db + +# Vacuum to reclaim space (offline only) +sqlite3 databases/vault.db "VACUUM;" + +# Analyse for query optimisation +sqlite3 databases/vault.db "ANALYZE;" +``` + +**Schedule maintenance**: + +```bash +#!/bin/bash +# monthly-maintenance.sh + +for db in databases/*.db; do + echo "Optimising $db" + sqlite3 "$db" "PRAGMA optimize;" + sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);" +done +``` + +### Version History Cleanup + +VaultLink stores **all versions indefinitely** by default. Database grows with every change. + +**Database schema**: Each version stored in `documents` table with `vault_update_id` (sequential). + +Manual cleanup (keep last 100 versions per document): + +```bash +#!/bin/bash +# prune-old-versions.sh + +for db in databases/*.db; do + sqlite3 "$db" < /dev/null; then + echo "Health check failed at $(date)" | mail -s "VaultLink Down" admin@example.com + # Optionally restart + # docker restart vaultlink-server + fi + sleep 30 +done +``` + +### Backup Automation + +Automated backup script: + +```bash +#!/bin/bash +# backup-vaultlink.sh + +BACKUP_DIR="/backup/vaultlink" +DATA_DIR="/data" +DATE=$(date +%Y%m%d-%H%M%S) +RETENTION_DAYS=30 + +# Create backup directory +mkdir -p "$BACKUP_DIR/$DATE" + +# Backup databases (with WAL checkpoint) +for db in "$DATA_DIR"/databases/*.db; do + sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);" + cp "$db" "$BACKUP_DIR/$DATE/" + [ -f "${db}-wal" ] && cp "${db}-wal" "$BACKUP_DIR/$DATE/" + [ -f "${db}-shm" ] && cp "${db}-shm" "$BACKUP_DIR/$DATE/" +done + +# Backup configuration +cp "$DATA_DIR/config.yml" "$BACKUP_DIR/$DATE/" + +# Compress backup +tar -czf "$BACKUP_DIR/vaultlink-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE" +rm -rf "$BACKUP_DIR/$DATE" + +# Clean old backups +find "$BACKUP_DIR" -name "vaultlink-*.tar.gz" -mtime +$RETENTION_DAYS -delete + +# Upload to remote storage (optional) +# rclone copy "$BACKUP_DIR/vaultlink-$DATE.tar.gz" remote:backups/ +``` + +Schedule with cron: + +```cron +0 2 * * * /opt/vaultlink/backup-vaultlink.sh +``` + +### Restore from Backup + +```bash +#!/bin/bash +# restore-vaultlink.sh + +BACKUP_FILE="$1" +DATA_DIR="/data" + +if [ -z "$BACKUP_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +# Stop server +docker stop vaultlink-server + +# Extract backup +tar -xzf "$BACKUP_FILE" -C /tmp/ +BACKUP_DATE=$(basename "$BACKUP_FILE" .tar.gz | cut -d- -f2-) + +# Restore databases +cp /tmp/"$BACKUP_DATE"/databases/*.db "$DATA_DIR/databases/" + +# Restore config (careful!) +# cp /tmp/$BACKUP_DATE/config.yml "$DATA_DIR/" + +# Cleanup +rm -rf /tmp/"$BACKUP_DATE" + +# Start server +docker start vaultlink-server + +echo "Restore complete. Check server logs." +``` + +## Monitoring and Metrics + +### Prometheus Metrics + +While VaultLink doesn't expose metrics natively, monitor Docker: + +```yaml +# docker-compose.yml +services: + vaultlink-server: + image: ghcr.io/schmelczer/vault-link-server:latest + labels: + - "prometheus.io/scrape=true" + - "prometheus.io/port=3000" + + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + ports: + - 8080:8080 +``` + +### Log Analysis + +Analyze logs for insights: + +```bash +# Most active users +grep "authenticated" logs/*.log | cut -d"'" -f2 | sort | uniq -c | sort -rn + +# Failed authentications by IP +grep "Authentication failed" logs/*.log | grep -oP '\d+\.\d+\.\d+\.\d+' | sort | uniq -c | sort -rn + +# Upload activity +grep "Upload:" logs/*.log | wc -l + +# Average files per vault +grep "Sync complete" logs/*.log | grep -oP '\d+ files' | cut -d' ' -f1 | awk '{sum+=$1; count++} END {print sum/count}' +``` + +### Alerting + +Simple alerting with cron: + +```bash +#!/bin/bash +# alert-errors.sh + +ERROR_THRESHOLD=10 +ERROR_COUNT=$(grep -c "ERROR" logs/latest.log) + +if [ "$ERROR_COUNT" -gt "$ERROR_THRESHOLD" ]; then + echo "VaultLink has $ERROR_COUNT errors in the last hour" | \ + mail -s "VaultLink Alert" admin@example.com +fi +``` + +## Security Hardening + +### Network Isolation + +Run VaultLink in isolated network: + +```yaml +services: + vaultlink-server: + image: ghcr.io/schmelczer/vault-link-server:latest + networks: + - vaultlink-internal + - proxy-external + +networks: + vaultlink-internal: + internal: true + proxy-external: + driver: bridge +``` + +### Read-Only Root Filesystem + +Run with read-only root (mount writable volumes for data): + +```yaml +services: + vaultlink-server: + image: ghcr.io/schmelczer/vault-link-server:latest + read_only: true + volumes: + - ./data:/data + - /tmp +``` + +### Drop Capabilities + +Run with minimal privileges: + +```yaml +services: + vaultlink-server: + image: ghcr.io/schmelczer/vault-link-server:latest + security_opt: + - no-new-privileges:true + cap_drop: + - ALL +``` + +## Migration + +### Moving to New Server + +1. **Backup on old server**: + + ```bash + ./backup-vaultlink.sh + ``` + +2. **Transfer backup**: + + ```bash + scp vaultlink-backup.tar.gz new-server:/tmp/ + ``` + +3. **Restore on new server**: + + ```bash + ./restore-vaultlink.sh /tmp/vaultlink-backup.tar.gz + ``` + +4. **Update DNS/clients** to point to new server + +5. **Verify sync** on all clients + +### Version Upgrades + +```bash +# Pull latest image +docker pull ghcr.io/schmelczer/vault-link-server:latest + +# Backup first +./backup-vaultlink.sh + +# Stop old container +docker stop vaultlink-server +docker rm vaultlink-server + +# Start with new image +docker run -d \ + --name vaultlink-server \ + --restart unless-stopped \ + -p 3000:3000 \ + -v ./data:/data \ + ghcr.io/schmelczer/vault-link-server:latest \ + /app/sync_server /data/config.yml + +# Check logs +docker logs -f vaultlink-server +``` + +## Next Steps + +- [Understand the architecture →](/architecture/) +- [Deploy the server →](/guide/server-setup) +- [Configure clients →](/guide/obsidian-plugin) diff --git a/docs/config/authentication.md b/docs/config/authentication.md new file mode 100644 index 00000000..74977be7 --- /dev/null +++ b/docs/config/authentication.md @@ -0,0 +1,558 @@ +# Authentication Configuration + +VaultLink uses token-based authentication with per-user vault access control. This guide covers all authentication and authorization options. + +## Overview + +Authentication in VaultLink: + +- **Token-based**: Users authenticate with secure tokens +- **Configured in YAML**: All users defined in `config.yml` +- **Vault-level access**: Control which vaults each user can access +- **No password hashing**: Tokens are treated as secrets + +## Basic Configuration + +```yaml +users: + user_configs: + - name: alice + token: alice-secure-token-here + vault_access: + type: allow_access_to_all +``` + +## User Configuration Fields + +### `name` + +**Type**: String +**Required**: Yes + +Human-readable identifier for the user. Used in logs and auditing. + +```yaml +- name: alice +``` + +**Notes**: + +- Must be unique across all users +- Used for identification only, not authentication +- Appears in server logs +- Can be any string (e.g., email, username) + +### `token` + +**Type**: String +**Required**: Yes + +Authentication token for the user. Must be kept secret. + +```yaml +- token: 1a2b3c4d5e6f7g8h9i0j... +``` + +**Best practices**: + +- Generate with: `openssl rand -hex 32` +- Minimum length: 32 characters +- Use different token per user +- Never commit to version control +- Rotate periodically + +**Example token generation**: + +```bash +# Generate a secure token +openssl rand -hex 32 +# Output: a7f3c9d1e8b2f4a6c3d9e1f7b8a4c2d6e9f1a3b7c5d8e2f4a6b9c3d1e8f7a4b2 +``` + +### `vault_access` + +**Type**: Object +**Required**: Yes + +Defines which vaults the user can access. + +**Three modes**: + +1. `allow_access_to_all`: Access to all vaults +2. `allow_list`: Access to specific vaults only +3. `deny_list`: Access to all vaults except specific ones + +## Access Control Modes + +### Allow Access to All + +Grant access to every vault: + +```yaml +users: + user_configs: + - name: admin + token: admin-token + vault_access: + type: allow_access_to_all +``` + +**Use cases**: + +- Administrator accounts +- Personal single-user deployments +- Development/testing + +### Allow List + +Grant access only to specific vaults: + +```yaml +users: + user_configs: + - name: alice + token: alice-token + vault_access: + type: allow_list + allowed: + - personal + - shared-team + - project-alpha +``` + +**Use cases**: + +- Multi-user deployments +- Restricted access scenarios +- Separation of concerns + +**Notes**: + +- User can only access listed vaults +- Attempting to access other vaults returns authentication error +- Empty list = no access to any vault + +### Deny List + +Grant access to all vaults except specific ones: + +```yaml +users: + user_configs: + - name: bob + token: bob-token + vault_access: + type: deny_list + denied: + - restricted + - admin-only +``` + +**Use cases**: + +- Users with broad access except sensitive vaults +- Simplify configuration when most vaults are accessible + +**Notes**: + +- User can access any vault not in the deny list +- Attempting to access denied vaults returns authentication error + +## Multi-User Scenarios + +### Personal Use (Single User) + +```yaml +users: + user_configs: + - name: me + token: my-super-secret-token + vault_access: + type: allow_access_to_all +``` + +### Small Team (Shared Vaults) + +```yaml +users: + user_configs: + - name: alice + token: alice-token + vault_access: + type: allow_list + allowed: + - personal-alice + - team-shared + - name: bob + token: bob-token + vault_access: + type: allow_list + allowed: + - personal-bob + - team-shared + - name: charlie + token: charlie-token + vault_access: + type: allow_list + allowed: + - personal-charlie + - team-shared +``` + +### Organization (Mixed Access) + +```yaml +users: + user_configs: + - name: admin + token: admin-token + vault_access: + type: allow_access_to_all + + - name: developer + token: dev-token + vault_access: + type: allow_list + allowed: + - engineering-docs + - api-specs + - shared + + - name: designer + token: design-token + vault_access: + type: allow_list + allowed: + - design-docs + - brand-assets + - shared + + - name: readonly + token: readonly-token + vault_access: + type: allow_list + allowed: + - public-wiki +``` + +## Authentication Flow + +### Connection + +1. Client connects via WebSocket +2. Client sends authentication message: + ```json + { + "type": "auth", + "token": "user-token", + "vault": "vault-name" + } + ``` +3. Server validates: + - Token exists in config + - User has access to requested vault +4. Server responds: + - Success: Connection established + - Failure: Connection closed with error + +### Validation + +Server checks: + +1. **Token match**: Token exists in `user_configs` +2. **Vault access**: User has permission for vault +3. **Connection limits**: Not exceeding `max_clients_per_vault` + +### Errors + +**Invalid token**: + +``` +Authentication failed: Invalid token +``` + +**No vault access**: + +``` +Authentication failed: User does not have access to vault 'restricted' +``` + +**Connection limit**: + +``` +Connection rejected: Maximum clients reached for vault +``` + +## Security Best Practices + +### Token Generation + +Generate strong tokens: + +```bash +# 64 character hex token (256 bits) +openssl rand -hex 32 + +# Base64 encoded (256 bits) +openssl rand -base64 32 + +# UUID v4 +uuidgen +``` + +### Token Storage + +**In config file**: + +```yaml +users: + user_configs: + - name: alice + token: !ENV ALICE_TOKEN # Read from environment variable +``` + +**Load from environment**: + +```bash +export ALICE_TOKEN="$(openssl rand -hex 32)" +./sync_server config.yml +``` + +### Token Rotation + +Periodically change tokens: + +1. Generate new token +2. Update `config.yml` +3. Restart server +4. Update clients with new token + +### Token Revocation + +To revoke access: + +1. Remove user from `config.yml` +2. Restart server +3. User's connections will be rejected + +For immediate revocation: + +- Remove user from config +- Restart server +- Existing connections are terminated + +## Access Patterns + +### Read-Only Users + +VaultLink doesn't distinguish read-only vs read-write. Implement via client: + +```yaml +# Server: Grant access +users: + user_configs: + - name: readonly + token: readonly-token + vault_access: + type: allow_list + allowed: + - public + +# Client: Use CLI in read-only mode (mount vault read-only) +docker run -v /vault:/vault:ro ... +``` + +### Temporary Access + +Grant temporary access: + +1. Add user to config +2. Set reminder to remove later +3. Remove user when no longer needed +4. Restart server + +For automation: + +```bash +# Add user with expiry comment +echo " - name: temp-user # EXPIRES: 2024-12-31" >> config.yml +echo " token: temp-token" >> config.yml +``` + +### Shared Tokens (Not Recommended) + +Multiple users sharing a token: + +- All appear as same user in logs +- Can't revoke individual access +- Security risk if one person leaves + +**Instead**: Create separate users with same vault access. + +## Monitoring + +### Server Logs + +Authentication events are logged: + +``` +2024-01-01 12:00:00 INFO User 'alice' authenticated for vault 'personal' +2024-01-01 12:00:05 WARN Authentication failed: Invalid token from 192.168.1.100 +2024-01-01 12:00:10 WARN User 'bob' denied access to vault 'restricted' +``` + +### Audit Trail + +Monitor authentication: + +```bash +# View authentication logs +grep "authenticated" logs/*.log + +# View failed authentications +grep "Authentication failed" logs/*.log + +# View access denials +grep "denied access" logs/*.log +``` + +## Advanced Scenarios + +### Multiple Servers + +Same user across multiple server instances: + +```yaml +# Server 1 config.yml +users: + user_configs: + - name: alice + token: alice-global-token + vault_access: + type: allow_list + allowed: + - vault-1 + - vault-2 + +# Server 2 config.yml +users: + user_configs: + - name: alice + token: alice-global-token # Same token + vault_access: + type: allow_list + allowed: + - vault-3 + - vault-4 +``` + +### Service Accounts + +Tokens for automated systems: + +```yaml +users: + user_configs: + - name: backup-service + token: backup-service-token + vault_access: + type: allow_access_to_all + + - name: ci-pipeline + token: ci-token + vault_access: + type: allow_list + allowed: + - documentation + + - name: monitoring + token: monitoring-token + vault_access: + type: allow_list + allowed: + - metrics +``` + +### Dynamic Vault Access + +VaultLink doesn't support runtime user management. To change access: + +1. Update `config.yml` +2. Restart server +3. Users reconnect automatically + +For frequent changes, consider: + +- Over-provision access (deny list) +- Use external authentication proxy +- Script config updates + reload + +## Troubleshooting + +### Can't connect + +**Check token**: + +```bash +# Verify token in config matches client +grep "token:" config.yml +``` + +**Check vault name**: + +```bash +# Ensure vault is in allowed list +grep -A 5 "name: alice" config.yml +``` + +**Check server logs**: + +```bash +tail -f logs/*.log | grep -i auth +``` + +### Access denied + +**Verify vault access**: + +```yaml +# Check user's vault_access configuration +users: + user_configs: + - name: alice + vault_access: + type: allow_list + allowed: + - vault-name # Must match exactly +``` + +**Case sensitivity**: + +- Vault names are case-sensitive +- `Vault` ≠ `vault` +- Ensure exact match in config and client + +### Token not working + +**Check for typos**: + +- Extra spaces +- Hidden characters +- Wrong quotes in YAML + +**Regenerate token**: + +```bash +# Generate new token +openssl rand -hex 32 + +# Update config +# Restart server +# Update client +``` + +## Next Steps + +- [Server configuration reference →](/config/server) +- [Advanced configuration →](/config/advanced) +- [Deploy the server →](/guide/server-setup) diff --git a/docs/config/server.md b/docs/config/server.md new file mode 100644 index 00000000..26eb894a --- /dev/null +++ b/docs/config/server.md @@ -0,0 +1,489 @@ +# Server Configuration + +Complete reference for configuring the VaultLink sync server via `config.yml`. + +## Configuration File Format + +The server is configured using a YAML file passed as a command-line argument: + +```bash +/app/sync_server /path/to/config.yml +``` + +## Complete Example + +```yaml +database: + databases_directory_path: databases + max_connections_per_vault: 12 + cursor_timeout_seconds: 60 + +server: + host: 0.0.0.0 + port: 3000 + max_body_size_mb: 512 + max_clients_per_vault: 256 + response_timeout_seconds: 60 + +users: + user_configs: + - name: admin + token: your-secure-random-token + vault_access: + type: allow_access_to_all + - name: alice + token: alice-token + vault_access: + type: allow_list + allowed: + - personal + - shared + - name: bob + token: bob-token + vault_access: + type: deny_list + denied: + - restricted + +logging: + log_directory: logs + log_rotation: 7days +``` + +## Database Section + +### `databases_directory_path` + +**Type**: String +**Required**: Yes +**Default**: None + +Directory where SQLite database files are stored. One database file per vault. + +```yaml +database: + databases_directory_path: /data/databases +``` + +The directory structure: + +``` +databases/ +├── vault-1.db +├── vault-2.db +└── personal.db +``` + +**Notes**: + +- Path is relative to working directory or absolute +- Directory must be writable by the server process +- Ensure adequate disk space for vault data +- Back up this directory regularly + +### `max_connections_per_vault` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 12 + +Maximum concurrent database connections per vault. + +```yaml +database: + max_connections_per_vault: 12 +``` + +**Tuning**: + +- Higher values: Better performance under load +- Lower values: Less memory usage +- Typical range: 8-20 +- Consider: Number of concurrent users × average operations per user + +### `cursor_timeout_seconds` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 60 + +How long to keep database cursors alive for inactive clients. + +```yaml +database: + cursor_timeout_seconds: 60 +``` + +**Notes**: + +- Cursors track client sync state +- Timeout too short: Clients may need to re-sync frequently +- Timeout too long: More memory usage +- Typical range: 30-300 seconds + +## Server Section + +### `host` + +**Type**: String +**Required**: Yes +**Default**: None + +Network interface to bind the server to. + +```yaml +server: + host: 0.0.0.0 # All interfaces + # OR + host: 127.0.0.1 # Localhost only + # OR + host: 192.168.1.100 # Specific interface +``` + +**Common values**: + +- `0.0.0.0`: Listen on all network interfaces (production) +- `127.0.0.1`: Listen on localhost only (development/testing) +- Specific IP: Listen on specific interface + +### `port` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 3000 + +TCP port to listen on. + +```yaml +server: + port: 3000 +``` + +**Notes**: + +- Must be available (not in use) +- Privileged ports (< 1024) require root +- Common ports: 3000, 8080, 8888 +- Configure firewall to allow this port + +### `max_body_size_mb` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 512 + +Maximum size of HTTP request body in megabytes. + +```yaml +server: + max_body_size_mb: 512 +``` + +**Usage**: + +- Limits file upload size +- Prevents memory exhaustion attacks +- Must be larger than largest expected file +- Consider client `max_file_size_mb` settings + +**Tuning**: + +- Small vaults (mostly text): 100 MB +- Medium vaults (some images): 512 MB +- Large vaults (many images/PDFs): 1024+ MB + +### `max_clients_per_vault` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 256 + +Maximum concurrent clients per vault. + +```yaml +server: + max_clients_per_vault: 256 +``` + +**Notes**: + +- Limits concurrent WebSocket connections +- Prevents resource exhaustion +- Consider expected number of users +- Each client uses memory and file descriptors + +**Scaling**: + +- Personal use: 10-50 +- Small team: 50-100 +- Large team: 100-500 + +### `response_timeout_seconds` + +**Type**: Integer +**Required**: Yes +**Default**: None +**Recommended**: 60 + +Maximum time to wait for client responses. + +```yaml +server: + response_timeout_seconds: 60 +``` + +**Usage**: + +- Timeout for HTTP requests +- Timeout for WebSocket operations +- Clients disconnected if unresponsive + +**Tuning**: + +- Fast networks: 30 seconds +- Slow networks: 90-120 seconds +- Large file uploads: Increase proportionally + +## Users Section + +See [Authentication Configuration →](/config/authentication) for detailed user configuration. + +## Logging Section + +### `log_directory` + +**Type**: String +**Required**: Yes +**Default**: None + +Directory where log files are written. + +```yaml +logging: + log_directory: /data/logs + # OR + log_directory: logs # Relative to working directory +``` + +**Notes**: + +- Path is relative to working directory or absolute +- Directory must be writable +- Logs are rotated based on `log_rotation` +- Monitor disk usage + +### `log_rotation` + +**Type**: String +**Required**: Yes +**Default**: None + +How often to rotate log files. + +```yaml +logging: + log_rotation: 7days + # OR + log_rotation: 24hours + # OR + log_rotation: 30days +``` + +**Format**: `` + +**Units**: + +- `hours`: Hours (e.g., `12hours`, `24hours`) +- `days`: Days (e.g., `7days`, `30days`) + +**Recommendations**: + +- Development: `24hours` or `7days` +- Production: `7days` or `30days` +- High traffic: `24hours` (logs can be large) + +## Environment-Specific Configurations + +### Development + +```yaml +database: + databases_directory_path: ./databases + max_connections_per_vault: 8 + cursor_timeout_seconds: 30 + +server: + host: 127.0.0.1 + port: 3000 + max_body_size_mb: 100 + max_clients_per_vault: 10 + response_timeout_seconds: 30 + +users: + user_configs: + - name: dev + token: dev-token + vault_access: + type: allow_access_to_all + +logging: + log_directory: logs + log_rotation: 24hours +``` + +### Production + +```yaml +database: + databases_directory_path: /data/databases + max_connections_per_vault: 16 + cursor_timeout_seconds: 120 + +server: + host: 0.0.0.0 + port: 3000 + max_body_size_mb: 512 + max_clients_per_vault: 256 + response_timeout_seconds: 90 + +users: + user_configs: + - name: admin + token: + vault_access: + type: allow_access_to_all + # Additional users... + +logging: + log_directory: /data/logs + log_rotation: 7days +``` + +## Validation + +The server validates configuration on startup: + +```bash +# Start server +./sync_server config.yml + +# Check for errors in logs +tail -f logs/latest.log +``` + +**Common errors**: + +- Missing required fields +- Invalid YAML syntax +- Invalid values (negative numbers, etc.) +- Directory not writable + +## Performance Tuning + +### High Concurrency + +For many concurrent users: + +```yaml +database: + max_connections_per_vault: 20 # Increase + +server: + max_clients_per_vault: 500 # Increase + response_timeout_seconds: 120 # Increase for slow clients +``` + +### Large Files + +For vaults with large files: + +```yaml +server: + max_body_size_mb: 1024 # Allow larger uploads + response_timeout_seconds: 180 # More time for uploads +``` + +### Resource-Constrained Systems + +For limited CPU/memory: + +```yaml +database: + max_connections_per_vault: 6 # Reduce + +server: + max_clients_per_vault: 50 # Reduce + max_body_size_mb: 256 # Reduce +``` + +## Security Considerations + +### Token Security + +- Use strong random tokens: `openssl rand -hex 32` +- Never commit tokens to version control +- Rotate tokens periodically +- Use different tokens per user + +### Network Security + +- Bind to `127.0.0.1` if using reverse proxy on same host +- Use firewall to restrict access +- Enable SSL/TLS via reverse proxy + +### Resource Limits + +- Set `max_clients_per_vault` to prevent DoS +- Set `max_body_size_mb` to prevent memory exhaustion +- Configure `response_timeout_seconds` to prevent hanging connections + +## Troubleshooting + +### Server won't start + +**Check YAML syntax**: + +```bash +# Use a YAML validator +python -c 'import yaml, sys; yaml.safe_load(open("config.yml"))' +``` + +**Check file paths**: + +```bash +# Ensure directories exist and are writable +mkdir -p databases logs +chmod 755 databases logs +``` + +**Check port availability**: + +```bash +# Verify port is not in use +lsof -i :3000 +``` + +### High memory usage + +- Reduce `max_connections_per_vault` +- Reduce `max_clients_per_vault` +- Reduce `max_body_size_mb` +- Check for large vaults or many concurrent users + +### Slow performance + +- Increase `max_connections_per_vault` +- Increase database connection pool +- Use SSD for database storage +- Monitor database size (vacuum if needed) + +## Next Steps + +- [Configure authentication →](/config/authentication) +- [Advanced configuration options →](/config/advanced) +- [Deploy the server →](/guide/server-setup) diff --git a/docs/guide/alternatives.md b/docs/guide/alternatives.md new file mode 100644 index 00000000..7f314127 --- /dev/null +++ b/docs/guide/alternatives.md @@ -0,0 +1,324 @@ +# Comparison with Alternatives + +VaultLink is one of several solutions for synchronising Obsidian vaults. This page compares VaultLink with popular alternatives to help you choose the right tool. + +## 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: + +- Edit with **Obsidian desktop** on your laptop +- Edit with **Vim** on your server +- Edit with **VS Code** on your workstation +- Edit with **Obsidian mobile** on your phone +- Use the **CLI client** for automated workflows + +All changes merge automatically without conflict markers, regardless of which editor you use. This is possible because VaultLink uses [reconcile-text](/architecture/sync-algorithm#why-reconcile-text-over-crdts) for differential synchronisation rather than requiring operation-level tracking. + +## VaultLink's Core Strengths + +Before diving into comparisons: + +1. **Fully self-hosted**: Server and all components are open source +2. **Collaborative editing**: Real-time sync with operational transformation +3. **Automatic conflict resolution**: No manual intervention or paid features required +4. **Cursor tracking**: See where other users are editing +5. **Extensively tested**: Comprehensive test suite for server and client +6. **Editor freedom**: Use any text editor, not just Obsidian +7. **Production-ready**: Docker images, health checks, monitoring + +## Obsidian Sync Alternatives + +### Self-hosted LiveSync + +**Downloads**: ~300,000 +**Repository**: https://github.com/vrtmrz/obsidian-livesync + +**Overview**: CouchDB/IBM Cloudant-based sync with end-to-end encryption. + +| Aspect | Self-hosted LiveSync | VaultLink | +| ------------------------- | --------------------------- | -------------------------------------- | +| **Self-hosted** | Yes (CouchDB required) | Yes (single binary or Docker) | +| **Conflict resolution** | Manual or automatic (basic) | Automatic (operational transformation) | +| **Collaborative editing** | No | Yes (real-time with cursors) | +| **Editor support** | Obsidian only | Any text editor | +| **Infrastructure** | CouchDB database | SQLite (bundled) | +| **Deployment complexity** | Medium (external DB) | Low (single container) | +| **End-to-end encryption** | Yes | No (transport encryption only) | +| **Out-of-band edits** | Limited support | Full support (edit with any tool) | + +**When to use LiveSync**: + +- Need end-to-end encryption +- Already running CouchDB +- Only use Obsidian (no external editors) + +**When to use VaultLink**: + +- Want collaborative editing with multiple users +- Edit files with various tools (Vim, VS Code, etc.) +- Need simpler deployment (no external database) +- Want operational transformation for better merges + +--- + +### Remotely Save + +**Downloads**: ~1.1M +**Repository**: https://github.com/remotely-save/remotely-save + +**Overview**: Sync to cloud storage providers (S3, Dropbox, OneDrive, WebDAV). + +| Aspect | Remotely Save | VaultLink | +| ------------------------- | ---------------------------- | ------------------------ | +| **Self-hosted** | Partial (uses cloud storage) | Fully self-hosted | +| **Conflict resolution** | Paid Pro feature | Free and automatic | +| **Collaborative editing** | No | Yes | +| **Editor support** | Obsidian only | Any text editor | +| **Storage backend** | Cloud providers | Self-hosted SQLite | +| **Cost** | Free (basic) / Paid (Pro) | Free (open source) | +| **Code quality** | No tests, complex codebase | Comprehensive test suite | +| **Real-time sync** | No (periodic polling) | Yes (WebSocket) | + +**When to use Remotely Save**: + +- Already use cloud storage (S3, Dropbox) +- Don't need real-time sync +- Single-user scenario + +**When to use VaultLink**: + +- Want full control over data +- Need automatic conflict resolution without paying +- Want real-time collaborative editing +- Value code quality and testing + +**Note**: Remotely Save's conflict resolution is a paid feature. VaultLink provides superior automatic merging for free. + +--- + +### Relay + +**Downloads**: ~24,000 +**Repository**: https://github.com/No-Instructions/Relay + +**Overview**: CRDT-based sync with proprietary server component. + +| Aspect | Relay | VaultLink | +| -------------------------- | ---------------------------- | ----------------------- | +| **Self-hosted** | No (proprietary server) | Yes (fully open source) | +| **Conflict resolution** | CRDT (automatic) | OT (automatic) | +| **Collaborative editing** | Yes | Yes | +| **Editor support** | Obsidian only | Any text editor | +| **Out-of-band edits** | No (breaks CRDT consistency) | Yes (differential sync) | +| **Server open source** | No | Yes | +| **Infrastructure control** | Limited | Full | +| **Per-file overhead** | High (CRDT metadata) | Low (version history) | + +**When to use Relay**: + +- Want hosted solution (don't self-host) +- Only edit within Obsidian +- Don't need out-of-band editing + +**When to use VaultLink**: + +- Need fully open source solution +- Want to self-host completely +- Edit files outside Obsidian (Vim, VS Code) +- Value infrastructure control + +**Critical limitation**: Relay's CRDT approach requires tracking every operation within Obsidian. Editing files outside Obsidian breaks the CRDT state. VaultLink's differential sync works regardless of how files are edited. + +--- + +### Obsidian Git + +**Downloads**: ~1.4M +**Repository**: https://github.com/denolehov/obsidian-git + +**Overview**: Uses Git for version control and synchronisation. + +| Aspect | Obsidian Git | VaultLink | +| ------------------------- | ----------------------------- | ----------------------- | +| **Self-hosted** | Yes (Git server) | Yes (sync server) | +| **Conflict resolution** | Manual (conflict markers) | Automatic (no markers) | +| **Collaborative editing** | No | Yes (real-time) | +| **Editor support** | Any (it's Git) | Any (differential sync) | +| **Version history** | Full Git history | Document versions | +| **Real-time sync** | No (commit-based) | Yes (instant) | +| **Merge conflicts** | Manual resolution | Automatic | +| **Learning curve** | High (Git knowledge required) | Low | +| **Workflow interruption** | Yes (resolve conflicts) | No | + +**When to use Obsidian Git**: + +- Need full version control (branches, tags, etc.) +- Already familiar with Git workflows +- Want integration with existing Git repos +- Don't mind manual conflict resolution + +**When to use VaultLink**: + +- Want automatic conflict-free merging +- Need real-time collaborative editing +- Don't want workflow interruptions from merge conflicts +- Prefer simpler mental model (sync, not commits) + +**Key difference**: Git requires manual conflict resolution with `<<<<<<<` markers. VaultLink automatically merges all changes using operational transformation, never interrupting your workflow. + +--- + +### Syncthing Integration + +**Downloads**: ~22,600 +**Repository**: https://github.com/LBF38/obsidian-syncthing-integration + +**Overview**: Wrapper around Syncthing for file synchronisation. + +| Aspect | Syncthing Integration | VaultLink | +| ------------------------- | ------------------------------ | ----------------- | +| **Self-hosted** | Yes (Syncthing) | Yes (sync server) | +| **Conflict resolution** | Manual | Automatic | +| **Collaborative editing** | No | Yes | +| **Editor support** | Any | Any | +| **Status** | Unfinished | Production-ready | +| **Conflict files** | Creates `.sync-conflict` files | No conflict files | +| **Real-time sync** | Yes | Yes | +| **Automatic merging** | No | Yes | + +**When to use Syncthing Integration**: + +- Already use Syncthing for other files +- Don't need automatic conflict resolution +- Single-user with multiple devices + +**When to use VaultLink**: + +- Want automatic conflict resolution +- Need collaborative editing +- Want production-ready solution +- Don't want to manage conflict files + +**Status note**: Syncthing Integration is marked as unfinished. VaultLink is production-ready with comprehensive testing. + +--- + +### Remotely Sync + +**Downloads**: ~38,000 +**Repository**: https://github.com/sboesen/remotely-sync + +**Overview**: Similar to Remotely Save, syncs to cloud storage. + +| Aspect | Remotely Sync | VaultLink | +| ----------------------- | ----------------------- | ------------------- | +| **Self-hosted** | Partial (cloud storage) | Fully self-hosted | +| **Conflict resolution** | Limited/Paid | Free and automatic | +| **Code quality** | No tests | Comprehensive tests | +| **Maintenance** | Low activity | Active development | + +**Same concerns as Remotely Save**: No test suite, conflict resolution limitations, cloud storage dependency. + +**When to use VaultLink**: See Remotely Save comparison above. + +--- + +### SyncFTP + +**Downloads**: ~5,000 +**Repository**: https://github.com/alex-donnan/SyncFTP + +**Overview**: Simple FTP-based file synchronisation. + +| Aspect | SyncFTP | VaultLink | +| ------------------------- | ---------------------- | ---------------- | +| **Conflict resolution** | None (last write wins) | Automatic (OT) | +| **Data loss risk** | High (overwrites) | None (merges) | +| **Collaborative editing** | No | Yes | +| **Sophistication** | Minimal | Production-grade | + +**When to use SyncFTP**: Don't use SyncFTP for any scenario where data integrity matters. + +**When to use VaultLink**: Any scenario requiring reliable synchronisation. + +--- + +## Feature Comparison Matrix + +| Feature | VaultLink | LiveSync | Relay | Git | Remotely Save | Syncthing | +| --------------------------------- | --------- | -------- | ----- | --- | ------------- | --------- | +| **Fully open source** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | +| **Self-hosted** | ✅ | ✅ | ❌ | ✅ | Partial | ✅ | +| **Automatic conflict resolution** | ✅ | Basic | ✅ | ❌ | Paid | ❌ | +| **Real-time sync** | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| **Collaborative editing** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | +| **Cursor tracking** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Editor agnostic** | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ | +| **Out-of-band edits** | ✅ | Limited | ❌ | ✅ | ❌ | ✅ | +| **No conflict markers** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| **Comprehensive tests** | ✅ | ❌ | ❌ | N/A | ❌ | N/A | +| **Simple deployment** | ✅ | ❌ | N/A | ❌ | ✅ | ❌ | +| **Low infrastructure** | ✅ | ❌ | N/A | ✅ | ✅ | ✅ | + +--- + +## VaultLink's Unique Position + +VaultLink is the **only** solution that combines: + +1. **Fully open source** self-hosted server +2. **Editor agnostic** operation (not locked to Obsidian) +3. **Automatic conflict-free merging** using operational transformation +4. **Real-time collaborative editing** with cursor tracking +5. **Differential synchronisation** supporting out-of-band edits +6. **Comprehensive test coverage** ensuring reliability +7. **Simple deployment** via Docker or single binary + +## Use Case Recommendations + +### Choose VaultLink when you: + +- Edit vaults with multiple editors (Obsidian + Vim + VS Code) +- Need real-time collaboration with teammates +- Want automatic conflict resolution without manual intervention +- Value full control over infrastructure +- Need production-ready reliability with comprehensive testing +- Want to edit files while offline and sync later seamlessly + +### Consider alternatives when you: + +- **LiveSync**: Need end-to-end encryption and only use Obsidian +- **Git**: Need full version control with branches and advanced Git features +- **Remotely Save**: Already committed to cloud storage providers +- **Syncthing**: Already use Syncthing and don't need automatic merging + +## Migration from Other Solutions + +VaultLink works with plain Markdown files, making migration simple: + +1. **From Git**: Clone your repo, point VaultLink to the directory +2. **From cloud sync**: Download files, configure VaultLink client +3. **From LiveSync**: Export vault, import to VaultLink +4. **From Syncthing**: Point VaultLink to synced directory + +All solutions work with the same Markdown files—VaultLink just syncs them better. + +## Beyond Obsidian + +Because VaultLink is editor-agnostic, you can use it for: + +- **Documentation teams**: Sync technical docs edited in VS Code +- **Academic writing**: Collaborate on papers with various Markdown editors +- **Personal knowledge bases**: Use Obsidian on mobile, Vim on servers +- **Automated workflows**: CLI client for backup systems and CI/CD +- **Multi-tool workflows**: Different team members use different editors + +VaultLink doesn't lock you into Obsidian—it's a general-purpose differential sync system that happens to work excellently with Obsidian vaults. + +## Next Steps + +Ready to try VaultLink? + +- [Get started →](/guide/getting-started) +- [Understand the architecture →](/architecture/) +- [See how sync works →](/architecture/sync-algorithm) diff --git a/docs/guide/cli-client.md b/docs/guide/cli-client.md new file mode 100644 index 00000000..eeb11131 --- /dev/null +++ b/docs/guide/cli-client.md @@ -0,0 +1,532 @@ +# CLI Client + +Sync vaults without Obsidian. Works on servers, automation, backups, headless systems. + +## Installation + +### Docker (Recommended) + +Pull the latest image: + +```bash +docker pull ghcr.io/schmelczer/vault-link-cli:latest +``` + +### npm + +Install globally: + +```bash +npm install -g @schmelczer/local-client-cli +``` + +Verify installation: + +```bash +vaultlink --version +``` + +### From Source + +Build from the repository: + +```bash +git clone https://github.com/schmelczer/vault-link.git +cd vault-link/frontend/local-client-cli +npm install +npm run build +node dist/cli.js --help +``` + +## Usage + +### Basic Usage + +```bash +vaultlink \ + --local-path /path/to/vault \ + --remote-uri wss://sync.example.com \ + --token your-auth-token \ + --vault-name default +``` + +### Docker Usage + +```bash +docker run -v /path/to/vault:/vault \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault \ + -r wss://sync.example.com \ + -t your-auth-token \ + -v default +``` + +### Docker Compose + +Create `docker-compose.yml`: + +```yaml +services: + vaultlink-cli: + image: ghcr.io/schmelczer/vault-link-cli:latest + restart: unless-stopped + volumes: + - ./vault:/vault + command: + - "-l" + - "/vault" + - "-r" + - "wss://sync.example.com" + - "-t" + - "your-token" + - "-v" + - "default" +``` + +Start the client: + +```bash +docker compose up -d +``` + +## Configuration Options + +### Required Arguments + +| Argument | Short | Description | Example | +| -------------- | ----- | ----------------------- | ------------------------ | +| `--local-path` | `-l` | Local directory to sync | `/vault` | +| `--remote-uri` | `-r` | Server WebSocket URI | `wss://sync.example.com` | +| `--token` | `-t` | Authentication token | `abc123...` | +| `--vault-name` | `-v` | Vault name on server | `default` | + +### Optional Arguments + +| Argument | Default | Description | +| ------------------------------- | ------- | -------------------------------------- | +| `--sync-concurrency` | `1` | Concurrent file operations | +| `--max-file-size-mb` | `10` | Max file size in MB | +| `--ignore-pattern` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms` | `3500` | Reconnection interval | +| `--log-level` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | + +### Environment Variables + +Alternative to command-line arguments: + +```bash +export VAULTLINK_LOCAL_PATH="/vault" +export VAULTLINK_REMOTE_URI="wss://sync.example.com" +export VAULTLINK_TOKEN="your-token" +export VAULTLINK_VAULT_NAME="default" + +vaultlink +``` + +## Examples + +### Basic Sync + +Sync a local directory to the server: + +```bash +vaultlink \ + -l ./my-notes \ + -r wss://sync.example.com \ + -t my-secure-token \ + -v personal +``` + +### With Ignore Patterns + +Exclude specific files or directories: + +```bash +vaultlink \ + -l ./vault \ + -r wss://sync.example.com \ + -t token123 \ + -v default \ + --ignore-pattern "*.tmp" \ + --ignore-pattern ".DS_Store" \ + --ignore-pattern "node_modules/**" +``` + +### Debug Logging + +Enable verbose logging: + +```bash +vaultlink \ + -l ./vault \ + -r wss://sync.example.com \ + -t token123 \ + -v default \ + --log-level DEBUG +``` + +### High Concurrency + +Faster initial sync: + +```bash +vaultlink \ + -l ./vault \ + -r wss://sync.example.com \ + -t token123 \ + -v default \ + --sync-concurrency 5 +``` + +### Large Files + +Allow larger file uploads: + +```bash +vaultlink \ + -l ./vault \ + -r wss://sync.example.com \ + -t token123 \ + -v default \ + --max-file-size-mb 50 +``` + +## Docker Deployment + +### Long-Running Sync + +Run as a daemon for continuous synchronisation: + +```bash +docker run -d \ + --name vaultlink-sync \ + --restart unless-stopped \ + -v $(pwd)/vault:/vault \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault \ + -r wss://sync.example.com \ + -t your-token \ + -v default +``` + +Monitor logs: + +```bash +docker logs -f vaultlink-sync +``` + +### Health Monitoring + +The Docker image includes built-in health checks: + +```bash +# Check health status +docker ps + +# View detailed health info +docker inspect --format='{{json .State.Health}}' vaultlink-sync | jq +``` + +Health check verifies: + +- Health file exists +- Status updated within last 30 seconds +- WebSocket connection is active + +Configure custom health check: + +```yaml +services: + vaultlink-cli: + image: ghcr.io/schmelczer/vault-link-cli:latest + healthcheck: + test: ["CMD", "node", "/app/healthcheck.js"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s +``` + +### Read-Only Vault + +Mount vault as read-only to prevent local changes: + +```bash +docker run -d \ + -v $(pwd)/vault:/vault:ro \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault \ + -r wss://sync.example.com \ + -t token \ + -v default +``` + +::: warning +The CLI needs write access to create `.vaultlink` metadata directory. Mount as read-write or provide a separate writeable directory. +::: + +## How It Works + +### Initial Sync + +On startup: + +1. Creates `.vaultlink/` directory for metadata +2. Scans local filesystem +3. Uploads all local files to server +4. Downloads files from server not present locally +5. Resolves conflicts using operational transformation + +### Real-Time Synchronization + +After initial sync: + +1. Watches filesystem for changes using `fs.watch` +2. Uploads changed files immediately +3. Receives real-time updates from server via WebSocket +4. Handles bidirectional sync automatically + +### Graceful Shutdown + +On SIGINT (Ctrl+C) or SIGTERM: + +1. Completes pending uploads +2. Closes WebSocket connection cleanly +3. Flushes metadata to disk +4. Exits gracefully + +## Use Cases + +### Automated Backups + +Continuously backup vaults to a remote server: + +```bash +docker run -d \ + --name vault-backup \ + -v /important/notes:/vault:ro \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault -r wss://backup.example.com -t backup-token -v backups +``` + +### CI/CD Documentation + +Sync documentation in automated pipelines: + +```bash +# In your CI pipeline +docker run \ + -v $(pwd)/docs:/vault \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault -r wss://docs.example.com -t ci-token -v prod-docs +``` + +### Multi-Location Sync + +Sync between different geographic locations: + +```bash +# Location A +vaultlink -l /data/vault -r wss://hub.example.com -t token -v shared + +# Location B +vaultlink -l /backup/vault -r wss://hub.example.com -t token -v shared +``` + +### Development Environment + +Keep documentation in sync across dev environments: + +```bash +# In docker-compose.yml for your dev stack +services: + docs-sync: + image: ghcr.io/schmelczer/vault-link-cli:latest + volumes: + - ./docs:/vault + command: ["-l", "/vault", "-r", "wss://docs-server", "-t", "dev-token", "-v", "dev"] +``` + +## Troubleshooting + +### Client won't connect + +**Check server accessibility**: + +```bash +curl https://sync.example.com/vaults/test/ping +``` + +**Verify WebSocket protocol**: + +- Use `ws://` for HTTP servers +- Use `wss://` for HTTPS servers + +**Check authentication**: + +- Token must match server config +- User must have access to the vault + +### Permission errors + +**Docker volume permissions**: + +```bash +# Ensure directory is writable +chmod 755 /path/to/vault + +# Check Docker user ID +docker run --rm ghcr.io/schmelczer/vault-link-cli:latest id +``` + +**SELinux issues**: + +```bash +# Add :z flag to volume mount +docker run -v /path/to/vault:/vault:z ... +``` + +### Files not syncing + +**Check ignore patterns**: + +- View logs to see which files are skipped +- Ensure patterns don't match unintentionally + +**File size limits**: + +- Check `--max-file-size-mb` setting +- Large files are skipped with a warning + +**Check metadata**: + +```bash +# View sync metadata +cat /path/to/vault/.vaultlink/metadata.json +``` + +### High memory usage + +**Reduce concurrency**: + +```bash +--sync-concurrency 1 +``` + +**Limit file sizes**: + +```bash +--max-file-size-mb 5 +``` + +**Check vault size**: + +- Very large vaults may need more resources +- Consider splitting into multiple vaults + +### Connection keeps dropping + +**Increase retry interval**: + +```bash +--websocket-retry-interval-ms 5000 +``` + +**Check network stability**: + +```bash +# Monitor connection +docker logs -f vaultlink-sync | grep -i websocket +``` + +**Server timeout settings**: + +- Verify reverse proxy WebSocket timeout +- Check server `response_timeout_seconds` + +## Advanced Usage + +### Custom Healthcheck Script + +Create your own health monitoring: + +```bash +#!/bin/bash +HEALTH_FILE="/tmp/vaultlink-health.json" + +if [ ! -f "$HEALTH_FILE" ]; then + exit 1 +fi + +# Check file is recent (within 60 seconds) +if [ $(( $(date +%s) - $(stat -c %Y "$HEALTH_FILE") )) -gt 60 ]; then + exit 1 +fi + +# Check WebSocket is connected +if ! jq -e '.connected == true' "$HEALTH_FILE" > /dev/null; then + exit 1 +fi + +exit 0 +``` + +### Automated Recovery + +Restart on failure with exponential backoff: + +```bash +#!/bin/bash +RETRY_DELAY=5 + +while true; do + vaultlink -l /vault -r wss://server -t token -v default + + echo "Client exited, restarting in ${RETRY_DELAY}s..." + sleep $RETRY_DELAY + + # Exponential backoff up to 5 minutes + RETRY_DELAY=$((RETRY_DELAY * 2)) + if [ $RETRY_DELAY -gt 300 ]; then + RETRY_DELAY=300 + fi +done +``` + +### Integration with systemd + +Create `/etc/systemd/system/vaultlink-cli.service`: + +```ini +[Unit] +Description=VaultLink CLI Sync +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=10 +Environment="VAULTLINK_LOCAL_PATH=/data/vault" +Environment="VAULTLINK_REMOTE_URI=wss://sync.example.com" +Environment="VAULTLINK_TOKEN=your-token" +Environment="VAULTLINK_VAULT_NAME=default" +ExecStart=/usr/local/bin/vaultlink + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable vaultlink-cli +sudo systemctl start vaultlink-cli +``` + +## Next Steps + +- [Configure server authentication →](/config/authentication) +- [Learn about the sync algorithm →](/architecture/sync-algorithm) +- [Set up Obsidian plugin →](/guide/obsidian-plugin) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md new file mode 100644 index 00000000..02b20ae0 --- /dev/null +++ b/docs/guide/getting-started.md @@ -0,0 +1,125 @@ +# Getting Started + +Set up VaultLink in 5 minutes. Deploy server, connect clients, done. + +## Prerequisites + +- Docker (or Rust toolchain if building from source) +- A server (VPS, home server, or localhost for testing) + +## Step 1: Deploy Server + +Create `config.yml`: + +```yaml +database: + databases_directory_path: databases + max_connections_per_vault: 12 + cursor_timeout_seconds: 60 +server: + host: 0.0.0.0 + port: 3000 + max_body_size_mb: 512 + max_clients_per_vault: 256 + response_timeout_seconds: 60 +users: + user_configs: + - name: admin + token: change-this-to-secure-random-token + vault_access: + type: allow_access_to_all +logging: + log_directory: logs + log_rotation: 7days +``` + +::: tip +Generate secure token: `openssl rand -hex 32` +::: + +Run server: + +```bash +docker run -d \ + --name vaultlink-server \ + --restart unless-stopped \ + -p 3000:3000 \ + -v $(pwd):/data \ + ghcr.io/schmelczer/vault-link-server:latest \ + /app/sync_server /data/config.yml +``` + +Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status + +## Step 2: Connect Client + +### 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) + - Token: Your token from config.yml + - Vault Name: `default` + +[Full plugin guide →](/guide/obsidian-plugin) + +### CLI Client + +```bash +docker run -d \ + --name vaultlink-cli \ + --restart unless-stopped \ + -v /path/to/vault:/vault \ + ghcr.io/schmelczer/vault-link-cli:latest \ + -l /vault -r ws://localhost:3000 -t your-token -v default +``` + +[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/) diff --git a/docs/guide/limitations.md b/docs/guide/limitations.md new file mode 100644 index 00000000..1c514939 --- /dev/null +++ b/docs/guide/limitations.md @@ -0,0 +1,192 @@ +# Limitations + +VaultLink works well for most Obsidian vaults, but has some constraints you should know about. + +## File Type Limitations + +### Mergeable Files + +Only **`.md`** and **`.txt`** files get automatic conflict-free merging. + +Other 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: + +- 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/) diff --git a/docs/guide/obsidian-plugin.md b/docs/guide/obsidian-plugin.md new file mode 100644 index 00000000..5b63e43d --- /dev/null +++ b/docs/guide/obsidian-plugin.md @@ -0,0 +1,276 @@ +# Obsidian Plugin + +Real-time sync for Obsidian vaults. + +## Installation + +### From Obsidian Community Plugins + +1. Open Obsidian Settings +2. Navigate to **Community Plugins** +3. Click **Browse** and search for "VaultLink" +4. Click **Install** +5. Enable the plugin + +### Manual Installation + +1. Download the latest release from [GitHub Releases](https://github.com/schmelczer/vault-link/releases) +2. Extract `main.js`, `manifest.json`, and `styles.css` +3. Copy to `.obsidian/plugins/vault-link/` in your vault +4. Reload Obsidian +5. Enable VaultLink in Community Plugins settings + +## Configuration + +After installation, configure the plugin in **Settings → VaultLink**. + +### Required Settings + +#### Server URL + +The WebSocket URL of your sync server. + +- **Development/Local**: `ws://localhost:3000` +- **Production (SSL)**: `wss://sync.example.com` + +::: tip +Use `ws://` for unencrypted connections and `wss://` for SSL connections (production). +::: + +#### Authentication Token + +Your authentication token from the server's `config.yml`. + +Generate a secure token: + +```bash +openssl rand -hex 32 +``` + +#### Vault Name + +The name of the vault on the server. Can be any string. + +Multiple Obsidian vaults can sync to the same server vault name (for shared vaults), or use unique names for separate vaults. + +### Optional Settings + +#### Sync Concurrency + +Number of files to sync simultaneously. + +- **Default**: 1 +- **Range**: 1-10 +- Higher values = faster initial sync, more resource usage + +#### Max File Size + +Maximum file size to sync (in MB). + +- **Default**: 10 +- Files larger than this are skipped + +#### Ignore Patterns + +Glob patterns for files to exclude from sync. + +Examples: + +- `*.tmp` - Ignore temporary files +- `.trash/**` - Ignore trash folder +- `private/**` - Ignore private directory + +#### WebSocket Retry Interval + +Milliseconds between reconnection attempts when disconnected. + +- **Default**: 3500ms +- Increase for flaky networks to avoid connection spam + +## Usage + +### Initial Sync + +When first connecting: + +1. The plugin uploads all local files to the server +2. Downloads any missing files from the server +3. Resolves any conflicts using operational transformation +4. Begins real-time synchronisation + +Initial sync time depends on vault size and `sync_concurrency` setting. + +### Real-Time Sync + +Once connected: + +- **File changes**: Automatically synced when saved +- **File creation**: New files immediately uploaded +- **File deletion**: Deletions propagated to other clients +- **File renames**: Tracked and synchronised + +The plugin watches your vault filesystem and syncs changes in real-time via WebSocket. + +### Status Indicators + +The plugin provides visual feedback: + +- **Connected**: Green status in settings +- **Syncing**: Progress indicator during uploads +- **Disconnected**: Red status, automatic reconnection attempts +- **Error**: Error message in settings and console + +Check the Obsidian console (Ctrl+Shift+I / Cmd+Option+I) for detailed logs. + +## Features + +### Automatic Conflict Resolution + +When multiple users edit the same file simultaneously, operational transformation merges changes automatically: + +- All edits are preserved +- No manual conflict resolution required +- Changes appear in real-time as others type + +### Mobile Support + +VaultLink works on Obsidian mobile (iOS and Android): + +- Same configuration as desktop +- Real-time sync across all devices +- Handle network changes gracefully + +::: warning +Ensure your sync server is accessible from mobile networks (use WSS with a public domain or VPN). +::: + +### Offline Support + +The plugin handles offline scenarios: + +- Continue working when disconnected +- Changes queue locally +- Automatic sync when connection restored +- Conflict resolution if others edited the same files + +## Collaboration Workflows + +### Personal Multi-Device Sync + +Sync the same vault across devices: + +1. Configure each Obsidian instance with the same vault name +2. Use the same authentication token +3. All devices stay in sync automatically + +### Team Shared Vault + +Multiple users collaborating: + +1. Each user has their own token (configured in server `config.yml`) +2. All users connect to the same vault name +3. Real-time collaborative editing with automatic conflict resolution + +### Selective Sharing + +Share specific folders while keeping others private: + +1. Use different vault names for shared vs. private content +2. Configure access control on the server per vault +3. Use ignore patterns to exclude sensitive directories + +## Troubleshooting + +### Plugin won't connect + +1. **Verify server is running**: + + ```bash + curl http://your-server:3000/vaults/test/ping + ``` + + Should return `pong` + +2. **Check URL format**: + - Local: `ws://localhost:3000` + - Remote (SSL): `wss://sync.example.com` + - Don't include `/vault/name` in the URL + +3. **Verify token**: + - Must match server config exactly + - No extra spaces or quotes + - Check server logs for authentication errors + +4. **Check firewall**: + - Ensure port is accessible from your network + - For mobile, server must be publicly accessible or use VPN + +### Files not syncing + +1. **Check ignore patterns**: File may match an exclusion pattern +2. **File size**: Check if file exceeds `max_file_size_mb` +3. **Permissions**: Ensure vault directory is readable/writable +4. **Console errors**: Open dev tools (Ctrl+Shift+I) and check console + +### Slow initial sync + +1. **Increase concurrency**: Set `sync_concurrency` higher (e.g., 5) +2. **Network speed**: Check internet connection +3. **Server resources**: Ensure server isn't overloaded +4. **Large files**: Consider increasing timeout settings + +### Conflicts not resolving + +Operational transformation should handle conflicts automatically. If issues persist: + +1. Check console for sync errors +2. Verify both clients are connected +3. Check server logs for processing errors +4. Ensure files are text-based (binary files may not merge well) + +### High CPU/Memory usage + +1. **Reduce concurrency**: Lower `sync_concurrency` +2. **Add ignore patterns**: Exclude unnecessary files +3. **File watchers**: Large vaults may trigger many filesystem events +4. **Check for sync loops**: Ensure no circular dependencies + +## Advanced Configuration + +### Multiple Vaults + +To sync multiple Obsidian vaults to different server vaults: + +1. Each Obsidian vault has its own VaultLink plugin configuration +2. Use different vault names for each +3. Can use the same or different tokens (depending on access control) + +### Custom Sync Patterns + +Combine ignore patterns for fine-grained control: + +``` +# Ignore patterns +*.tmp +*.bak +.DS_Store +.trash/** +private/** +drafts/**/*.draft.md +``` + +### Development/Testing + +For plugin development: + +1. Clone the repository +2. `cd frontend && npm install` +3. `npm run dev` to build in watch mode +4. Plugin rebuilds automatically on changes +5. Reload Obsidian to test changes + +## Next Steps + +- [Learn about the sync algorithm →](/architecture/sync-algorithm) +- [Configure the server →](/config/server) +- [Set up the CLI client →](/guide/cli-client) diff --git a/docs/guide/server-setup.md b/docs/guide/server-setup.md new file mode 100644 index 00000000..1848db26 --- /dev/null +++ b/docs/guide/server-setup.md @@ -0,0 +1,379 @@ +# Server Setup + +Deploy VaultLink server via Docker, binary, or build from source. + +## Deployment Options + +### Docker (Recommended) + +Easiest deployment path, includes health checks. + +#### Basic Docker Deployment + +```bash +# Pull the latest image +docker pull ghcr.io/schmelczer/vault-link-server:latest + +# Create data directory +mkdir -p ~/vaultlink-data + +# Create config.yml (see Configuration section below) + +# Run the container +docker run -d \ + --name vaultlink-server \ + --restart unless-stopped \ + -p 3000:3000 \ + -v ~/vaultlink-data:/data \ + ghcr.io/schmelczer/vault-link-server:latest \ + /app/sync_server /data/config.yml +``` + +#### Docker Compose + +Create `docker-compose.yml`: + +```yaml +services: + vaultlink-server: + image: ghcr.io/schmelczer/vault-link-server:latest + container_name: vaultlink-server + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ./data:/data + command: ["/app/sync_server", "/data/config.yml"] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/vaults/fake/ping"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s +``` + +Start the server: + +```bash +docker compose up -d +``` + +### Binary Installation + +Download pre-built binaries from [GitHub Releases](https://github.com/schmelczer/vault-link/releases): + +```bash +# Download the binary for your platform +wget https://github.com/schmelczer/vault-link/releases/latest/download/sync_server-linux-x86_64 + +# Make executable +chmod +x sync_server-linux-x86_64 + +# Run the server +./sync_server-linux-x86_64 config.yml +``` + +### Build from Source + +Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI + +```bash +# Clone the repository +git clone https://github.com/schmelczer/vault-link.git +cd vault-link/sync-server + +# Install SQLx CLI +cargo install sqlx-cli + +# Set up the database +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 + +# Build in release mode +cargo build --release + +# Run the server +./target/release/sync_server config.yml +``` + +## Configuration + +Create a `config.yml` file with your server configuration: + +```yaml +database: + databases_directory_path: databases + max_connections_per_vault: 12 + cursor_timeout_seconds: 60 + +server: + host: 0.0.0.0 + port: 3000 + max_body_size_mb: 512 + max_clients_per_vault: 256 + response_timeout_seconds: 60 + +users: + user_configs: + - name: admin + token: your-secure-random-token-here + vault_access: + type: allow_access_to_all + +logging: + log_directory: logs + log_rotation: 7days +``` + +### Configuration Fields + +#### Database + +- `databases_directory_path`: Directory for SQLite databases (one per vault) +- `max_connections_per_vault`: Maximum concurrent database connections +- `cursor_timeout_seconds`: How long to keep database cursors alive + +#### Server + +- `host`: Bind address (use `0.0.0.0` for all interfaces) +- `port`: Port to listen on (default: 3000) +- `max_body_size_mb`: Maximum upload size +- `max_clients_per_vault`: Concurrent client limit per vault +- `response_timeout_seconds`: Request timeout + +#### Users + +See [Authentication Configuration →](/config/authentication) for detailed user setup. + +#### Logging + +- `log_directory`: Where to store log files +- `log_rotation`: How often to rotate logs (e.g., `7days`, `24hours`) + +## Production Deployment + +### SSL/TLS with Reverse Proxy + +VaultLink doesn't handle SSL directly. Use a reverse proxy like Nginx or Caddy. + +#### Nginx Configuration + +```nginx +upstream vaultlink { + server localhost:3000; +} + +server { + listen 443 ssl http2; + server_name sync.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://vaultlink; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket specific + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } +} +``` + +Reload Nginx: + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +#### Caddy Configuration + +Caddy handles SSL automatically: + +```caddy +sync.example.com { + reverse_proxy localhost:3000 +} +``` + +Start Caddy: + +```bash +caddy run --config Caddyfile +``` + +### Systemd Service + +Create `/etc/systemd/system/vaultlink.service`: + +```ini +[Unit] +Description=VaultLink Sync Server +After=network.target + +[Service] +Type=simple +User=vaultlink +WorkingDirectory=/opt/vaultlink +ExecStart=/opt/vaultlink/sync_server /opt/vaultlink/config.yml +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable vaultlink +sudo systemctl start vaultlink +sudo systemctl status vaultlink +``` + +### Security Best Practices + +1. **Use strong tokens**: Generate with `openssl rand -hex 32` +2. **Enable firewall**: Only expose port 3000 to reverse proxy +3. **Regular updates**: Keep Docker images and binaries updated +4. **Backup databases**: SQLite files in `databases_directory_path` +5. **Monitor logs**: Check log directory for errors and anomalies +6. **Limit access**: Use vault-specific access controls per user + +### Backup Strategy + +The SQLite databases contain all vault data and history: + +```bash +# Backup script +#!/bin/bash +BACKUP_DIR="/backup/vaultlink/$(date +%Y%m%d)" +DATA_DIR="/data/databases" + +mkdir -p "$BACKUP_DIR" +cp -r "$DATA_DIR" "$BACKUP_DIR/" + +# Keep 30 days of backups +find /backup/vaultlink -type d -mtime +30 -exec rm -rf {} + +``` + +Run daily via cron: + +```cron +0 2 * * * /opt/vaultlink/backup.sh +``` + +### Monitoring + +#### Health Checks + +The server exposes a ping endpoint: + +```bash +curl http://localhost:3000/vaults/test/ping +# Returns: {"server_version":"0.10.1","is_authenticated":false} +``` + +Replace `test` with any vault name. The endpoint returns: + +- `server_version`: Current server version +- `is_authenticated`: Whether the request included a valid token + +Docker health check is built-in and checks this endpoint every 30 seconds. + +#### Prometheus Metrics + +For advanced monitoring, collect Docker stats or implement custom metrics. + +#### Log Monitoring + +Logs are written to the configured `log_directory`. Monitor for: + +- Connection failures +- Authentication errors +- Database errors +- WebSocket disconnections + +Example log watching: + +```bash +tail -f /data/logs/*.log | grep -i error +``` + +## Scaling + +### Horizontal Scaling + +VaultLink currently uses SQLite, which limits horizontal scaling. For multiple servers: + +1. Run separate instances for different vaults +2. Use load balancer with sticky sessions (same vault → same server) +3. Consider database architecture for your scale needs + +### Vertical Scaling + +Increase resources for the server: + +- More CPU for handling concurrent connections +- More RAM for database caching +- Faster storage (SSD) for database operations + +Tune configuration: + +- Increase `max_clients_per_vault` for more concurrent users +- Increase `max_connections_per_vault` for database performance +- Adjust `max_body_size_mb` based on typical file sizes + +## Troubleshooting + +### Server won't start + +```bash +# Check Docker logs +docker logs vaultlink-server + +# Common issues: +# - Port already in use: Change port mapping +# - Config syntax error: Validate YAML +# - Permission error: Check volume permissions +``` + +### High memory usage + +- Reduce `max_connections_per_vault` +- Reduce `max_clients_per_vault` +- Check for large vaults (may need database optimisation) + +### Database corruption + +```bash +# Verify database integrity +sqlite3 databases/your-vault.db "PRAGMA integrity_check;" + +# If corrupted, restore from backup +cp /backup/databases/your-vault.db /data/databases/ +``` + +### WebSocket connection drops + +- Check reverse proxy timeout settings +- Verify firewall isn't closing connections +- Review client retry intervals +- Check server logs for errors + +## Next Steps + +- [Configure authentication and access control →](/config/authentication) +- [Set up Obsidian plugin →](/guide/obsidian-plugin) +- [Deploy CLI client →](/guide/cli-client) +- [Understand the architecture →](/architecture/) diff --git a/docs/guide/what-is-vaultlink.md b/docs/guide/what-is-vaultlink.md new file mode 100644 index 00000000..070b312c --- /dev/null +++ b/docs/guide/what-is-vaultlink.md @@ -0,0 +1,71 @@ +# 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) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..6a7d610d --- /dev/null +++ b/docs/index.md @@ -0,0 +1,55 @@ +--- +layout: home + +hero: + name: VaultLink + text: Self-Hosted Obsidian Sync + tagline: Edit with any tool. Automatic conflict-free merging. Your infrastructure. + image: + src: /logo.svg + alt: VaultLink + actions: + - theme: brand + text: Get Started + link: /guide/getting-started + - theme: alt + text: Why VaultLink? + 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: 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 + - title: Real-Time Collaboration + details: See teammate cursors, merge edits instantly. Rust-powered WebSocket server with SQLite + - 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 +--- + +## Why Self-Host? + +**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. + +**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. + +**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) + +[See how VaultLink compares to alternatives →](/guide/alternatives) + +## Quick Start + +Deploy server (single command): + +```bash +docker run -d -p 3000:3000 -v $(pwd)/data:/data \ + ghcr.io/schmelczer/vault-link-server:latest +``` + +Then install the [Obsidian plugin](/guide/obsidian-plugin) or [CLI client](/guide/cli-client). + +[Full setup guide →](/guide/getting-started) diff --git a/docs/package-lock.json b/docs/package-lock.json new file mode 100644 index 00000000..d078bbe6 --- /dev/null +++ b/docs/package-lock.json @@ -0,0 +1,2989 @@ +{ + "name": "docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docs", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@cspell/dict-en-gb": "^5.0.19", + "cspell": "^9.3.2", + "prettier": "^3.6.2", + "vitepress": "^1.6.4", + "vue": "^3.5.24" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.10.0.tgz", + "integrity": "sha512-mQT3jwuTgX8QMoqbIR7mPlWkqQqBPQaPabQzm37xg2txMlaMogK/4hCiiESGdg39MlHZOVHeV+0VJuE7f5UK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.44.0.tgz", + "integrity": "sha512-KY5CcrWhRTUo/lV7KcyjrZkPOOF9bjgWpMj9z98VA+sXzVpZtkuskBLCKsWYFp2sbwchZFTd3wJM48H0IGgF7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.44.0.tgz", + "integrity": "sha512-LKOCE8S4ewI9bN3ot9RZoYASPi8b78E918/DVPW3HHjCMUe6i+NjbNG6KotU4RpP6AhRWZjjswbOkWelUO+OoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.44.0.tgz", + "integrity": "sha512-1yyJm4OYC2cztbS28XYVWwLXdwpLsMG4LoZLOltVglQ2+hc/i9q9fUDZyjRa2Bqt4DmkIfezagfMrokhyH4uxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.44.0.tgz", + "integrity": "sha512-wVQWK6jYYsbEOjIMI+e5voLGPUIbXrvDj392IckXaCPvQ6vCMTXakQqOYCd+znQdL76S+3wHDo77HZWiAYKrtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.44.0.tgz", + "integrity": "sha512-lkgRjOjOkqmIkebHjHpU9rLJcJNUDMm+eVSW/KJQYLjGqykEZxal+nYJJTBbLceEU2roByP/+27ZmgIwCdf0iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.44.0.tgz", + "integrity": "sha512-sYfhgwKu6NDVmZHL1WEKVLsOx/jUXCY4BHKLUOcYa8k4COCs6USGgz6IjFkUf+niwq8NCECMmTC4o/fVQOalsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.44.0.tgz", + "integrity": "sha512-/FRKUM1G4xn3vV8+9xH1WJ9XknU8rkBGlefruq9jDhYUAvYozKimhrmC2pRqw/RyHhPivmgZCRuC8jHP8piz4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.44.0.tgz", + "integrity": "sha512-5+S5ynwMmpTpCLXGjTDpeIa81J+R4BLH0lAojOhmeGSeGEHQTqacl/4sbPyDTcidvnWhaqtyf8m42ue6lvISAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.44.0.tgz", + "integrity": "sha512-xhaTN8pXJjR6zkrecg4Cc9YZaQK2LKm2R+LkbAq+AYGBCWJxtSGlNwftozZzkUyq4AXWoyoc0x2SyBtq5LRtqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.44.0.tgz", + "integrity": "sha512-GNcite/uOIS7wgRU1MT7SdNIupGSW+vbK9igIzMePvD2Dl8dy0O3urKPKIbTuZQqiVH1Cb84y5cgLvwNrdCj/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.44.0.tgz", + "integrity": "sha512-YZHBk72Cd7pcuNHzbhNzF/FbbYszlc7JhZlDyQAchnX5S7tcemSS96F39Sy8t4O4WQLpFvUf1MTNedlitWdOsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.44.0.tgz", + "integrity": "sha512-B9WHl+wQ7uf46t9cq+vVM/ypVbOeuldVDq9OtKsX2ApL2g/htx6ImB9ugDOOJmB5+fE31/XPTuCcYz/j03+idA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.44.0.tgz", + "integrity": "sha512-MULm0qeAIk4cdzZ/ehJnl1o7uB5NMokg83/3MKhPq0Pk7+I0uELGNbzIfAkvkKKEYcHALemKdArtySF9eKzh/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-9.3.2.tgz", + "integrity": "sha512-OmKzq/0FATHU671GKMzBrTyLdm25Wnziva7h4ylumVn1wnwWsXGef5bgXD7iuApqfqH9SzxsU0NtTB8m8vwEHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.16", + "@cspell/dict-bash": "^4.2.2", + "@cspell/dict-companies": "^3.2.7", + "@cspell/dict-cpp": "^6.0.14", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.7", + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-dart": "^2.3.1", + "@cspell/dict-data-science": "^2.0.11", + "@cspell/dict-django": "^4.1.5", + "@cspell/dict-docker": "^1.1.16", + "@cspell/dict-dotnet": "^5.0.10", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.24", + "@cspell/dict-en-common-misspellings": "^2.1.8", + "@cspell/dict-en-gb-mit": "^3.1.14", + "@cspell/dict-filetypes": "^3.0.14", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.5", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.7", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.0.7", + "@cspell/dict-golang": "^6.0.24", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^4.0.4", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.12", + "@cspell/dict-monkeyc": "^1.0.11", + "@cspell/dict-node": "^5.0.8", + "@cspell/dict-npm": "^5.2.22", + "@cspell/dict-php": "^4.1.0", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.15", + "@cspell/dict-python": "^4.2.21", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.0.9", + "@cspell/dict-rust": "^4.0.12", + "@cspell/dict-scala": "^5.0.8", + "@cspell/dict-shell": "^1.1.2", + "@cspell/dict-software-terms": "^5.1.13", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5", + "@cspell/dict-zig": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-9.3.2.tgz", + "integrity": "sha512-YRgpeHN9uY8kUlIw9q+8zJ0tRTAJMbfBTGzCq9Puah09NeMWlRMFPUkXVrkdic6NA7etboZ+zEdoZwRO9EmhiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-9.3.2.tgz", + "integrity": "sha512-REF7ibG79WLEynIMUss/IRDCdYEb1nlE1rj/gt2CbPFzLa6t5MRwW2lajEvXS6/WgbMtsTVHAWi3ALqJzCwxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-9.3.2.tgz", + "integrity": "sha512-jLN2Aa/vxm8+IBvTd884SwPEfjxnDwIEPBT3hmqgLlKuUHQ3FMG27lsM4Ik9L2KWBXMgV/wGz4BaxfhKI41Ttw==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^4.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-9.3.2.tgz", + "integrity": "sha512-/rB8LazM0JzKL+AvZa5fEpLutmwy5QFMpzw8HJd+rDGkzb5r79hURWSRo84QArgaskUqA9XlOHSieDE9pt+WAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-9.3.2.tgz", + "integrity": "sha512-l4H8bMAmdzCbXHO8y1JZiAKszrPEiuLFKWrbhCacHF0iP+PIc/yuQp7cO70m0p70vArRfih6kgGyHFaCy47CfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.16.tgz", + "integrity": "sha512-a681zShZbtTo947NvTYGLer95ZDQw1ROKvIFydak1e0OlfFCsNdtcYTupn0nbbYs53c9AO7G2DU8AcNEAnwXPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.2.tgz", + "integrity": "sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.1.2" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.7.tgz", + "integrity": "sha512-fEyr3LmpFKTaD0LcRhB4lfW1AmULYBqzg4gWAV0dQCv06l+TsA+JQ+3pZJbUcoaZirtgsgT3dL3RUjmGPhUH0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "6.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-6.0.14.tgz", + "integrity": "sha512-dkmpSwvVfVdtoZ4mW/CK2Ep1v8mJlp6uiKpMNbSMOdJl4kq28nQS4vKNIX3B2bJa0Ha5iHHu+1mNjiLeO3g7Xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.7.tgz", + "integrity": "sha512-H16Hpu8O/1/lgijFt2lOk4/nnldFtQ4t8QHbyqphqZZVE5aS4J/zD/WvduqnLY21aKhZS6jo/xF5PX9jyqPKUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.0.18.tgz", + "integrity": "sha512-EF77RqROHL+4LhMGW5NTeKqfUd/e4OOv6EDFQ/UQQiFyWuqkEKyEz0NDILxOFxWUEVdjT2GQ2cC7t12B6pESwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.1.tgz", + "integrity": "sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.12.tgz", + "integrity": "sha512-vI/mg6cI28IkFcpeINS7cm5M9HWemmXSTnxJiu3nmc4VAGx35SXIEyuLGBcsVzySvDablFYf4hsEpmg1XpVsUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.5.tgz", + "integrity": "sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.16.tgz", + "integrity": "sha512-UiVQ5RmCg6j0qGIxrBnai3pIB+aYKL3zaJGvXk1O/ertTKJif9RZikKXCEgqhaCYMweM4fuLqWSVmw3hU164Iw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.10.tgz", + "integrity": "sha512-ooar8BP/RBNP1gzYfJPStKEmpWy4uv/7JCq6FOnJLeD1yyfG3d/LFMVMwiJo+XWz025cxtkM3wuaikBWzCqkmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.24.tgz", + "integrity": "sha512-JE+/H2YicHJTneRmgH4GSI21rS+1yGZVl1jfOQgl8iHLC+yTTMtCvueNDMK94CgJACzYAoCsQB70MqiFJJfjLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.8.tgz", + "integrity": "sha512-vDsjRFPQGuAADAiitf82z9Mz3DcqKZi6V5hPAEIFkLLKjFVBcjUsSq59SfL59ElIFb76MtBO0BLifdEbBj+DoQ==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb": { + "version": "5.0.19", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb/-/dict-en-gb-5.0.19.tgz", + "integrity": "sha512-/p+p/9q8XTzsE0GxbZZKcC1rTLYmCpilYw8aC9Q1xJbve8YqZnpxk8IxRyaHwfy1TeKMQNs6heZZRtzPag0rCw==", + "dev": true, + "license": "LGPL-3.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.14.tgz", + "integrity": "sha512-b+vEerlHP6rnNf30tmTJb7JZnOq4WAslYUvexOz/L3gDna9YJN3bAnwRJ3At3bdcOcMG7PTv3Pi+C73IR22lNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.14.tgz", + "integrity": "sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.5.tgz", + "integrity": "sha512-BbpkX10DUX/xzHs6lb7yzDf/LPjwYIBJHJlUXSBXDtK/1HaeS+Wqol4Mlm2+NAgZ7ikIE5DQMViTgBUY3ezNoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.7.tgz", + "integrity": "sha512-IxEk2YAwAJKYCUEgEeOg3QvTL4XLlyArJElFuMQevU1dPgHgzWElFevN5lsTFnvMFA1riYsVinqJJX0BanCFEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.0.7.tgz", + "integrity": "sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.24.tgz", + "integrity": "sha512-rY7PlC3MsHozmjrZWi0HQPUl0BVCV0+mwK0rnMT7pOIXqOe4tWCYMULDIsEk4F0gbIxb5badd2dkCPDYjLnDgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.12.tgz", + "integrity": "sha512-JFffQ1dDVEyJq6tCDWv0r/RqkdSnV43P2F/3jJ9rwLgdsOIXwQbXrz6QDlvQLVvNSnORH9KjDtenFTGDyzfCaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.4.tgz", + "integrity": "sha512-afea+0rGPDeOV9gdO06UW183Qg6wRhWVkgCFwiO3bDupAoyXRuvupbb5nUyqSTsLXIKL8u8uXQlJ9pkz07oVXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-4.0.4.tgz", + "integrity": "sha512-YdTQhnTINEEm/LZgTzr9Voz4mzdOXH7YX+bSFs3hnkUHCUUtX/mhKgf1CFvZ0YNM2afjhQcmLaR9bDQVyYBvpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.12.tgz", + "integrity": "sha512-ufwoliPijAgWkD/ivAMC+A9QD895xKiJRF/fwwknQb7kt7NozTLKFAOBtXGPJAB4UjhGBpYEJVo2elQ0FCAH9A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.0.18", + "@cspell/dict-html": "^4.0.12", + "@cspell/dict-html-symbol-entities": "^4.0.4", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.11.tgz", + "integrity": "sha512-7Q1Ncu0urALI6dPTrEbSTd//UK0qjRBeaxhnm8uY5fgYNFYAG+u4gtnTIo59S6Bw5P++4H3DiIDYoQdY/lha8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.8.tgz", + "integrity": "sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.23", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.23.tgz", + "integrity": "sha512-cnlPGzhNkbXFLFURfjzwML2LjHMofqJkemR7lLo9Jwa9IptvzeTn4nOtJMSGfkxNrZPf/IvQ7rH5hamsUQLQ3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.0.tgz", + "integrity": "sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.15.tgz", + "integrity": "sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.22", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.22.tgz", + "integrity": "sha512-rgF7DuleVK2lkzlw33jjEfxS2a0CU5kwAhOqf5B6XkuaPbqZ/0g0LBCdwglAGccYu7sBuvxRS8Yubk+ytSAFTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.12" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.0.9.tgz", + "integrity": "sha512-H2vMcERMcANvQshAdrVx0XoWaNX8zmmiQN11dZZTQAZaNJ0xatdJoSqY8C8uhEMW89bfgpN+NQgGuDXW2vmXEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.0.12.tgz", + "integrity": "sha512-z2QiH+q9UlNhobBJArvILRxV8Jz0pKIK7gqu4TgmEYyjiu1TvnGZ1tbYHeu9w3I/wOP6UMDoCBTty5AlYfW0mw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.8.tgz", + "integrity": "sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.1.2.tgz", + "integrity": "sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.1.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.1.14.tgz", + "integrity": "sha512-Eu9h090hxHJiqzVFS0WxOZbYXnmb7F1RFIUEg4Nru+D/78bXVDH4b8BiKGVFNRljaieNQRAHaryzdaKJRCH6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-zig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", + "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-9.3.2.tgz", + "integrity": "sha512-au7FyuIHUNI2r9sO3pUBKVTeD/v7c9x/nPUStaAK1bG4rdKt4w+/jUY2IaldAraW5w29z528BboXbiV87SM1kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/filetypes": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-9.3.2.tgz", + "integrity": "sha512-0bUxQlmJPRHZrRQD7adbc4lFizO8tGD/6+1cBgU3kV3+NVrpr12y4jU8twCSChhYibZyPr7bnvhkM3cQgb8RzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-9.3.2.tgz", + "integrity": "sha512-pFcmOTWCoFMRETb9PCkCmaiZiLb5i2qOZmGH/p/tFEH8kIYhMGfhaulnXwKwS+Ke6PKceQd2YL98bGmo8hL4aQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@cspell/url": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-9.3.2.tgz", + "integrity": "sha512-TobUlZl7Z7VehhNOMNAg1ABuGizieseftlG94OZJ934JptOhK8TC/1o2ldKrbDH50jyt6E7rPTMV2BW/vWuTzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.59", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.59.tgz", + "integrity": "sha512-fYx/InyQsWFW4wVxWka3CGDJ6m/fXoTqWBSl+oA3FBXO5RhPAb6S3Y5bRgCPnrYevErH8VjAL0TZevIqlN2PhQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", + "integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.24", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz", + "integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz", + "integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.24", + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz", + "integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", + "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", + "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/shared": "3.5.24" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", + "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.24", + "@vue/runtime-core": "3.5.24", + "@vue/shared": "3.5.24", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", + "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "vue": "3.5.24" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz", + "integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.44.0.tgz", + "integrity": "sha512-f8IpsbdQjzTjr/4mJ/jv5UplrtyMnnciGax6/B0OnLCs2/GJTK13O4Y7Ff1AvJVAaztanH+m5nzPoUq6EAy+aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.10.0", + "@algolia/client-abtesting": "5.44.0", + "@algolia/client-analytics": "5.44.0", + "@algolia/client-common": "5.44.0", + "@algolia/client-insights": "5.44.0", + "@algolia/client-personalization": "5.44.0", + "@algolia/client-query-suggestions": "5.44.0", + "@algolia/client-search": "5.44.0", + "@algolia/ingestion": "1.44.0", + "@algolia/monitoring": "1.44.0", + "@algolia/recommend": "5.44.0", + "@algolia/requester-browser-xhr": "5.44.0", + "@algolia/requester-fetch": "5.44.0", + "@algolia/requester-node-http": "5.44.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/birpc": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", + "integrity": "sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clear-module": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/clear-module/-/clear-module-4.1.2.tgz", + "integrity": "sha512-LWAxzHqdHsAZlPlEyJ2Poz6AIs384mPeqLVCru2p0BrP9G/kVGuhNyZYClLO6cXlnuJjzC8xtsJIuMjKqLXoAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^2.0.0", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/comment-json": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.4.1.tgz", + "integrity": "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "core-util-is": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cspell": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-9.3.2.tgz", + "integrity": "sha512-3xFyVSTYrYa/QJzLfzsCRMkMXqOsytP8E26DuGrVMJQoLPFmbOXNNtnMu4wrtr17QVloxpvutW77U4vb2L/LDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "9.3.2", + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "@cspell/dynamic-import": "9.3.2", + "@cspell/url": "9.3.2", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "commander": "^14.0.2", + "cspell-config-lib": "9.3.2", + "cspell-dictionary": "9.3.2", + "cspell-gitignore": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-io": "9.3.2", + "cspell-lib": "9.3.2", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.3.3", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-9.3.2.tgz", + "integrity": "sha512-zXhmA4rqgWQRTVijI+g/mgiep76TvTO4d+P3CHwcqLG57BKVzoW+jkO4qDLC+Neh4b8+CcNWEIr3w16BfuEJAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "9.3.2", + "comment-json": "^4.4.1", + "smol-toml": "^1.5.2", + "yaml": "^2.8.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-dictionary": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-9.3.2.tgz", + "integrity": "sha512-E3YhOhZzZt1a+AEbFV2B3THCyZ576PDg0mDNUDrU1Y65SyIhf4DC6itfPoAb6R3FI/DI218RqWZg/FTT8lJ2gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "cspell-trie-lib": "9.3.2", + "fast-equals": "^5.3.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-gitignore": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-9.3.2.tgz", + "integrity": "sha512-G2bLR+Dfb9GX4Sdm75GfCCa9V/sQYkRbLckuCuVmJxvcDB0xfczAtb6TfAXIziF3oUI6cOB1g+PoNLWBelcK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-io": "9.3.2" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-glob": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-9.3.2.tgz", + "integrity": "sha512-TuSupENEKyOCupOUZ3vnPxaTOghxY/rD1JIkb8e5kjzRprYVilO/rYqEk/52iLwJVd+4Npe8fNhR3KhU7u/UUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "9.3.2", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-grammar": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-9.3.2.tgz", + "integrity": "sha512-ysonrFu9vJvF/derDlEjUfmvLeCfNOWPh00t6Yh093AKrJFoWQiyaS/5bEN/uB5/n1sa4k3ItnWvuTp3+YuZsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-io": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-9.3.2.tgz", + "integrity": "sha512-ahoULCp0j12TyXXmIcdO/7x65A/2mzUQO1IkOC65OXEbNT+evt0yswSO5Nr1F6kCHDuEKc46EZWwsYAzj78pMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "9.3.2", + "@cspell/url": "9.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-9.3.2.tgz", + "integrity": "sha512-kdk11kib68zNANNICuOA8h4oA9kENQUAdeX/uvT4+7eHbHHV8WSgjXm4k4o/pRIbg164UJTX/XxKb/65ftn5jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "9.3.2", + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-resolver": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "@cspell/dynamic-import": "9.3.2", + "@cspell/filetypes": "9.3.2", + "@cspell/strong-weak-map": "9.3.2", + "@cspell/url": "9.3.2", + "clear-module": "^4.1.2", + "cspell-config-lib": "9.3.2", + "cspell-dictionary": "9.3.2", + "cspell-glob": "9.3.2", + "cspell-grammar": "9.3.2", + "cspell-io": "9.3.2", + "cspell-trie-lib": "9.3.2", + "env-paths": "^3.0.0", + "gensequence": "^8.0.8", + "import-fresh": "^3.3.1", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cspell-trie-lib": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-9.3.2.tgz", + "integrity": "sha512-1Af7Mq9jIccFQyJl/ZCcqQbtJwuDqpQVkk8xfs/92x4OI6gW1iTVRMtsrh0RTw1HZoR8aQD7tRRCiLPf/D+UiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "9.3.2", + "@cspell/cspell-types": "9.3.2", + "gensequence": "^8.0.8" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/focus-trap": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", + "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.3.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensequence": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", + "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/parent-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-2.0.0.tgz", + "integrity": "sha512-uo0Z9JJeWzv8BG+tRcapBKNJ0dro9cLyczGzulS6EfeyAdeC9sbojtW6XwvYxJkEne9En+J2XEl4zyglVeIwFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.27.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", + "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/smol-toml": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.5.2.tgz", + "integrity": "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz", + "integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.24", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", + "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.24", + "@vue/compiler-sfc": "3.5.24", + "@vue/runtime-dom": "3.5.24", + "@vue/server-renderer": "3.5.24", + "@vue/shared": "3.5.24" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..2da4b6cb --- /dev/null +++ b/docs/package.json @@ -0,0 +1,25 @@ +{ + "name": "docs", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "vitepress dev --host", + "build": "vitepress build", + "preview": "vitepress preview", + "format": "prettier --write \"**/*.md\" \"**/*.mts\"", + "format:check": "prettier --check \"**/*.md\" \"**/*.mts\"", + "spell": "cspell \"**/*.md\" \"**/*.mts\"", + "spell:check": "cspell \"**/*.md\" \"**/*.mts\"" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@cspell/dict-en-gb": "^5.0.19", + "cspell": "^9.3.2", + "prettier": "^3.6.2", + "vitepress": "^1.6.4", + "vue": "^3.5.24" + } +} diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 00000000..31770331 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md new file mode 100644 index 00000000..6fa2848c --- /dev/null +++ b/frontend/deterministic-tests/README.md @@ -0,0 +1,118 @@ +# 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 +}; +``` diff --git a/frontend/deterministic-tests/package.json b/frontend/deterministic-tests/package.json new file mode 100644 index 00000000..4bd82c74 --- /dev/null +++ b/frontend/deterministic-tests/package.json @@ -0,0 +1,23 @@ +{ + "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" + } +} diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts new file mode 100644 index 00000000..6e15cac0 --- /dev/null +++ b/frontend/deterministic-tests/src/cli.ts @@ -0,0 +1,243 @@ +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 { + 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 { + 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 { + 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); +}); diff --git a/frontend/deterministic-tests/src/consts.ts b/frontend/deterministic-tests/src/consts.ts new file mode 100644 index 00000000..d9a2498f --- /dev/null +++ b/frontend/deterministic-tests/src/consts.ts @@ -0,0 +1,17 @@ +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; diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts new file mode 100644 index 00000000..b32b01c2 --- /dev/null +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -0,0 +1,483 @@ +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; + database: Partial; + }> = {}; + private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT; + private readonly syncErrors: Error[] = []; + private readonly pendingSyncOperations = new Set>(); + private readonly wsFactory = new ManagedWebSocketFactory(); + private nextWriteRename: + | { + oldPath: RelativePath; + newPath: RelativePath; + } + | undefined; + private nextCreateResponseDrop: + | { + dropped: Promise; + resolveDropped: () => void; + } + | undefined; + + public constructor( + clientId: number, + initialSettings: Partial, + logger: (msg: string) => void + ) { + super(); + this.clientId = clientId; + this.logger = logger; + this.data.settings = { ...initialSettings }; + } + + public async init( + fetchImplementation: typeof globalThis.fetch + ): Promise { + 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((resolve) => { + resolveDropped = resolve; + }); + this.nextCreateResponseDrop = { + dropped, + resolveDropped + }; + this.log("Armed next create response drop"); + } + + public async waitForDroppedCreateResponse(): Promise { + 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 { + const existing = this.client.getHistoryEntries().find(matches); + if (existing !== undefined) { + onMatch?.(existing); + return; + } + + await withTimeout( + new Promise((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 { + 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 { + 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 { + 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 { + this.log("Enabling sync"); + await this.client.setSetting("isSyncEnabled", true); + this.isSyncEnabled = true; + await this.waitForWebSocket(); + } + + public async getFileContent(path: string): Promise { + 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 { + 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 { + await Promise.resolve(); + return super.read(path); + } + + public override async write( + path: RelativePath, + content: Uint8Array + ): Promise { + 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 { + 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 { + await super.delete(path); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyDeletedFile(path); + }); + } + } + + public override async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + await super.rename(oldPath, newPath); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); + }); + } + } + + private async waitForWebSocket(): Promise { + 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 { + while (this.pendingSyncOperations.size > 0) { + await utils.awaitAll([...this.pendingSyncOperations]); + } + } + + private enqueueSync(operation: () => Promise): 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 + ): Promise { + 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); + } +} diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts new file mode 100644 index 00000000..c759891b --- /dev/null +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -0,0 +1,245 @@ +/** + * 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 + ): 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 + ): 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(); + } + } +} diff --git a/frontend/deterministic-tests/src/parse-args.ts b/frontend/deterministic-tests/src/parse-args.ts new file mode 100644 index 00000000..11c56f19 --- /dev/null +++ b/frontend/deterministic-tests/src/parse-args.ts @@ -0,0 +1,43 @@ +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 ", + "Run only tests whose name contains this substring" + ) + .option( + "-j, --concurrency ", + "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 }; +} diff --git a/frontend/deterministic-tests/src/prefixed-logger.ts b/frontend/deterministic-tests/src/prefixed-logger.ts new file mode 100644 index 00000000..769d7545 --- /dev/null +++ b/frontend/deterministic-tests/src/prefixed-logger.ts @@ -0,0 +1,28 @@ +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}`); + } +} diff --git a/frontend/deterministic-tests/src/run-with-concurrency.ts b/frontend/deterministic-tests/src/run-with-concurrency.ts new file mode 100644 index 00000000..f5bcf745 --- /dev/null +++ b/frontend/deterministic-tests/src/run-with-concurrency.ts @@ -0,0 +1,33 @@ +export async function runWithConcurrency( + items: T[], + concurrency: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = []; + const errors: unknown[] = []; + const executing = new Set>(); + + 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; +} diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts new file mode 100644 index 00000000..9cb4cde0 --- /dev/null +++ b/frontend/deterministic-tests/src/server-control.ts @@ -0,0 +1,296 @@ +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 { + 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 { + 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 { + 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 { + 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((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; + } + } +} diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts new file mode 100644 index 00000000..76c624f7 --- /dev/null +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -0,0 +1,71 @@ +import type { ServerControl } from "./server-control"; +import type { Logger } from "sync-client"; + +export class ServerManager { + private readonly activeServers = new Set(); + 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 { + 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(); + } + }); + } +} diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts new file mode 100644 index 00000000..bd832a50 --- /dev/null +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "./utils/assertable-state"; + +export interface ClientState { + files: Map; + clientFiles: Map[]; +} + +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; +} diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts new file mode 100644 index 00000000..1a07b411 --- /dev/null +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -0,0 +1,245 @@ +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> = { + "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 +}; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts new file mode 100644 index 00000000..411e9b08 --- /dev/null +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -0,0 +1,399 @@ +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 { + 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 { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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[] = []; + for (const agent of this.agents) { + const sortedFiles = (await agent.listFilesRecursively()).sort(); + const fileMap = new Map(); + 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 { + // 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"); + } +} diff --git a/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..467c19f0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts @@ -0,0 +1,40 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts new file mode 100644 index 00000000..8b934c1b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -0,0 +1,97 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts new file mode 100644 index 00000000..2d40228f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/catchup-create-and-update-not-skipped.test.ts @@ -0,0 +1,66 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts new file mode 100644 index 00000000..1972526a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts @@ -0,0 +1,59 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts new file mode 100644 index 00000000..aceb8baa --- /dev/null +++ b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts @@ -0,0 +1,53 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts new file mode 100644 index 00000000..88376f22 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts @@ -0,0 +1,32 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts new file mode 100644 index 00000000..5c141a0e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts @@ -0,0 +1,49 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts new file mode 100644 index 00000000..cd8046ce --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts @@ -0,0 +1,49 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts new file mode 100644 index 00000000..0ac0b721 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts @@ -0,0 +1,52 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts new file mode 100644 index 00000000..5337649d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -0,0 +1,61 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts new file mode 100644 index 00000000..0b72c0f3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts @@ -0,0 +1,39 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts new file mode 100644 index 00000000..d21ce16b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts @@ -0,0 +1,51 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts new file mode 100644 index 00000000..6c766001 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts @@ -0,0 +1,27 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts new file mode 100644 index 00000000..0fe51106 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts @@ -0,0 +1,50 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts new file mode 100644 index 00000000..ef7ea5c3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts @@ -0,0 +1,37 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts new file mode 100644 index 00000000..a9bc37d4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts @@ -0,0 +1,59 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts new file mode 100644 index 00000000..b9e16c90 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts @@ -0,0 +1,34 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts new file mode 100644 index 00000000..aa24b110 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -0,0 +1,36 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts new file mode 100644 index 00000000..9b752d05 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts @@ -0,0 +1,32 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts new file mode 100644 index 00000000..dfef9961 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -0,0 +1,40 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts new file mode 100644 index 00000000..3ba393b8 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -0,0 +1,35 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts new file mode 100644 index 00000000..6cb4cb98 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -0,0 +1,42 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts new file mode 100644 index 00000000..782c3cd5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -0,0 +1,54 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts new file mode 100644 index 00000000..dde8d341 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -0,0 +1,34 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts new file mode 100644 index 00000000..80e95f48 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-recreated-pending-create-with-stale-deleting-record.test.ts @@ -0,0 +1,52 @@ +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); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts new file mode 100644 index 00000000..91e6289b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -0,0 +1,43 @@ +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") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts new file mode 100644 index 00000000..cb995243 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts @@ -0,0 +1,38 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts new file mode 100644 index 00000000..744d862e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -0,0 +1,77 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts new file mode 100644 index 00000000..551c702d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -0,0 +1,33 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts new file mode 100644 index 00000000..3ae7eda5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -0,0 +1,29 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts new file mode 100644 index 00000000..20925889 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -0,0 +1,41 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts new file mode 100644 index 00000000..c2b80af3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-rename-survives-remote-rename.test.ts @@ -0,0 +1,80 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts new file mode 100644 index 00000000..0d8348c0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/local-update-survives-remote-rename.test.ts @@ -0,0 +1,69 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts new file mode 100644 index 00000000..d986a733 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -0,0 +1,46 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts new file mode 100644 index 00000000..6727e99d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -0,0 +1,39 @@ +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") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts new file mode 100644 index 00000000..8db90aab --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -0,0 +1,49 @@ +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") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts new file mode 100644 index 00000000..4167b925 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -0,0 +1,41 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts new file mode 100644 index 00000000..e93240f9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/merging-update-response-survives-user-rename.test.ts @@ -0,0 +1,77 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts new file mode 100644 index 00000000..86657f0f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -0,0 +1,43 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts new file mode 100644 index 00000000..fe9267d4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts @@ -0,0 +1,42 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts new file mode 100644 index 00000000..2a9ce0b4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts @@ -0,0 +1,44 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts new file mode 100644 index 00000000..13e27349 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -0,0 +1,48 @@ +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}"` + ); + } + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts new file mode 100644 index 00000000..433bf01b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -0,0 +1,38 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts new file mode 100644 index 00000000..4f5feab5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -0,0 +1,34 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts new file mode 100644 index 00000000..a47f5a2a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -0,0 +1,45 @@ +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") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts new file mode 100644 index 00000000..6c946b9c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -0,0 +1,59 @@ +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") + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts new file mode 100644 index 00000000..cbd59a4a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts @@ -0,0 +1,41 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts new file mode 100644 index 00000000..1e9ea8f7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -0,0 +1,38 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts new file mode 100644 index 00000000..21e81aa6 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -0,0 +1,46 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts new file mode 100644 index 00000000..ffc41b89 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -0,0 +1,49 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts new file mode 100644 index 00000000..970eabd3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -0,0 +1,51 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts new file mode 100644 index 00000000..da875b6e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -0,0 +1,57 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts new file mode 100644 index 00000000..f8e92bd9 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -0,0 +1,36 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts new file mode 100644 index 00000000..6341fe8f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -0,0 +1,40 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts new file mode 100644 index 00000000..836c7fb2 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -0,0 +1,43 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts new file mode 100644 index 00000000..c1b2913a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -0,0 +1,51 @@ +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" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts new file mode 100644 index 00000000..3442cda7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -0,0 +1,75 @@ +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"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts new file mode 100644 index 00000000..b951b0be --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts @@ -0,0 +1,34 @@ +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 "); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts new file mode 100644 index 00000000..f86b3347 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -0,0 +1,41 @@ +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); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts new file mode 100644 index 00000000..e0ddc21a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts @@ -0,0 +1,48 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { + description: + "Client 0 creates a binary file and updates it while client 1 also " + + "creates a binary file at the same path. Both clients are online. " + + "Both clients must end up with the same file set.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-websocket", client: 1 }, + { + type: "create", + client: 0, + path: "data.bin", + content: "BINARY:content-v1" + }, + { + type: "update", + client: 0, + path: "data.bin", + content: "BINARY:content-v2" + }, + { + type: "create", + client: 1, + path: "data.bin", + content: "BINARY:other-content" + }, + { type: "resume-websocket", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertNoFileContains("content-v1") + .assertAnyFileContains("content-v2") + .assertAnyFileContains("other-content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts new file mode 100644 index 00000000..de5d6c89 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -0,0 +1,37 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { + description: + "A file is deleted and recreated multiple times by alternating clients while both are online. " + + "Both clients must converge after each cycle.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "round 0" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 1" }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 1, path: "A.md", content: "round 2" }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 3" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "round 3"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts new file mode 100644 index 00000000..d3a9d84e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -0,0 +1,31 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const onlineEditVsDeleteConvergenceTest: TestDefinition = { + description: + "Both clients are online. Client 0 edits a file while client 1 " + + "deletes it. The clients must converge to the same state.", + 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: "update", + client: 0, + path: "A.md", + content: "edited by client 0" + }, + { type: "delete", client: 1, path: "A.md" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts new file mode 100644 index 00000000..a93a6f69 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -0,0 +1,54 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const overlappingEditsSameSectionTest: TestDefinition = { + description: + "Both clients go offline and edit different parts of the same document. " + + "After both reconnect, both edits must be preserved without data loss.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "doc.md", + content: "# Title\n\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: "# Title\nalpha addition\n\nfooter" + }, + + { + type: "update", + client: 1, + path: "doc.md", + content: "# Title\n\nbeta addition\nfooter" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "# Title", + "alpha addition", + "beta addition", + "footer" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts new file mode 100644 index 00000000..6d89acf4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { + description: + "Client 0 goes offline, both clients edit doc.md concurrently, " + + "then client 0 reconnects. Both edits must be preserved.", + 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: "alpha bravo" }, + { type: "sync", client: 1 }, + + { type: "update", client: 0, path: "doc.md", content: "charlie delta" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "alpha", + "charlie" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts new file mode 100644 index 00000000..a29f8314 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/queued-create-delete-does-not-hijack-reused-path.test.ts @@ -0,0 +1,56 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const queuedCreateDeleteDoesNotHijackReusedPathTest: TestDefinition = { + description: + "A create/delete pair that is still queued behind another request " + + "must collapse locally. It must not later read a different file " + + "that reused the same path before the queued create drained.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.bin", + content: "BINARY:blocker" + }, + { type: "sleep", ms: 100 }, + { + type: "create", + client: 1, + path: "target.bin", + content: "BINARY:old" + }, + { type: "delete", client: 1, path: "target.bin" }, + { + type: "create", + client: 1, + path: "source.bin", + content: "BINARY:new" + }, + { + type: "rename", + client: 1, + oldPath: "source.bin", + newPath: "target.bin" + }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.bin", "BINARY:blocker") + .assertContent("target.bin", "BINARY:new") + .assertFileNotExists("source.bin"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts new file mode 100644 index 00000000..f9c58753 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -0,0 +1,52 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { + description: + "Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " + + "After the server resumes, client 1 must see only the final file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "cycle.md", + content: "version 1" + }, + { + type: "update", + client: 0, + path: "cycle.md", + content: "version 2" + }, + { type: "delete", client: 0, path: "cycle.md" }, + + { type: "resume-server" }, + { type: "sync" }, + + { + type: "create", + client: 0, + path: "cycle.md", + content: "final creation" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "cycle.md", + "final creation" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts new file mode 100644 index 00000000..48c062e0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -0,0 +1,48 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { + description: + "Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " + + "Both clients must converge to a consistent 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: "create", client: 0, path: "C.md", content: "content C" }, + { type: "create", client: 0, path: "D.md", content: "content D" }, + { type: "create", client: 0, path: "E.md", content: "content E" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "A edit 1" }, + { type: "update", client: 0, path: "B.md", content: "B edit 1" }, + { type: "update", client: 0, path: "C.md", content: "C edit 1" }, + { type: "delete", client: 1, path: "A.md" }, + { type: "delete", client: 1, path: "C.md" }, + { type: "delete", client: 1, path: "E.md" }, + { type: "update", client: 0, path: "A.md", content: "A edit 2" }, + { type: "update", client: 0, path: "B.md", content: "B edit 2" }, + { type: "update", client: 0, path: "C.md", content: "C edit 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + for (const [path, content] of s.files) { + for (const clientFiles of s.clientFiles) { + if ( + clientFiles.has(path) && + clientFiles.get(path) !== content + ) { + throw new Error( + `Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"` + ); + } + } + } + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts new file mode 100644 index 00000000..6f97ff05 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -0,0 +1,49 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const rapidUpdatesAfterMergeTest: TestDefinition = { + description: + "Both clients create the same file offline, triggering a merge on sync. " + + "Client 0 then rapidly sends three updates. Both clients must converge to the final update.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "from client 0" }, + { type: "create", client: 1, path: "doc.md", content: "from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 1" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 2" + }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 0, + path: "doc.md", + content: "update 3" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains("doc.md", "update 3"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts new file mode 100644 index 00000000..c8e70243 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -0,0 +1,45 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { + description: + "After a client deletes a document and reconnects, it should " + + "accept new documents from other clients even if they happen to " + + "arrive at the same path as the deleted document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "doc.md", content: "original" }, + { type: "sync" }, + + { type: "delete", client: 0, path: "doc.md" }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "new content from client 1" + }, + + { 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).assertContent( + "doc.md", + "new content from client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts new file mode 100644 index 00000000..ca184b27 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-quick-write-rename-before-record.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteQuickWriteRenameBeforeRecordTest: TestDefinition = { + description: + "Client 0 receives a remote create and the user renames the new " + + "file immediately after the syncer writes it. The watcher event " + + "must bind to the new document instead of being dropped before " + + "the remote-create handler persists the record.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { + type: "rename-next-write", + client: 0, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "create", client: 1, path: "doc.md", content: "v1\n" }, + { type: "sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1); + s.assertFileExists("renamed.md"); + s.assertFileNotExists("doc.md"); + s.assertContent("renamed.md", "v1\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts new file mode 100644 index 00000000..d30fdc67 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-rename-collides-with-pending-local-create.test.ts @@ -0,0 +1,76 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteRenameCollidesWithPendingLocalCreateTest: TestDefinition = { + // TODO(refactor): the failure mode described below is the + // pre-refactor "deflect-to-conflict-uuid" path that no longer + // exists. Under the new model the wire loop never moves files for + // path placement, so the remote rename can't deflect anywhere; the + // reconciler waits for the slot to free. Convergence assertion is + // still valid (no conflict-uuid stashes, both files present, the + // local create lands at a server-deconflicted sibling). + description: + "Client 0 has doc D tracked at `original.md`. Client 1 owns doc E " + + "and renames it to `target.md` server-side. Before client 0's " + + "drain processes the WS broadcast for E, the user creates a new " + + "local file `target.md` (a different doc, untracked). When the " + + "buffered RemoteChange for E drains, the engine has to reconcile " + + "doc E onto `target.md` even though the slot is held by client " + + "0's pending LocalCreate. Convergence requires both clients end " + + "up with [target.md = E] and the local create lands at a " + + "server-deconflicted sibling (e.g. `target (1).md`).", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 1, path: "original.md", content: "v1\n" }, + { type: "barrier" }, + + // Pause client 0's WS so the upcoming remote rename buffers and + // we can stage a colliding local create before the rename + // drains on client 0. + { type: "pause-websocket", client: 0 }, + + // Client 1 renames the doc. Server commits, broadcasts to + // client 0 (buffered). + { + type: "rename", + client: 1, + oldPath: "original.md", + newPath: "target.md" + }, + { type: "sync", client: 1 }, + + // Client 0 still believes the doc is at `original.md`. The user + // creates a NEW file at `target.md` (an unrelated untracked + // doc). Disk on client 0 now has both `original.md` (the + // tracked doc) and `target.md` (the new untracked file). + { type: "create", client: 0, path: "target.md", content: "extra\n" }, + + // Resume client 0's WS. The buffered RemoteChange drains. + // The reconciler must converge without ever leaving a + // conflict-uuid stash on disk. + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + for (const path of state.files.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a converged client: ${path}` + ); + } + } + state.assertFileExists("target.md"); + state.assertContent("target.md", "v1\n"); + // The local create gets server-deconflicted to a + // sibling path (e.g. `target (1).md`). + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts new file mode 100644 index 00000000..eb2ed86d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-resurrects-deleted-doc.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteUpdateResurrectsDeletedDocTest: TestDefinition = { + description: + "Client 1 updates, deletes, and recreates P (with a new docId D2). " + + "While the buffered remote events are being processed by client 0, " + + "client 0 also makes a local edit to P. The local edit lands in the " + + "queue while v17 is mid-process, sending v17 down processRemoteUpdate's " + + "re-enqueue branch. The deferred v17 must NOT later resurrect D1 as a " + + "conflict-… file at P after the delete and the D2 create have drained.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 1, path: "P.md", content: "v8 content\n" }, + { type: "barrier" }, + + { type: "pause-websocket", client: 0 }, + + { + type: "update", + client: 1, + path: "P.md", + content: "v17 content from client 1\n" + }, + { type: "sync", client: 1 }, + { type: "delete", client: 1, path: "P.md" }, + { type: "sync", client: 1 }, + { + type: "create", + client: 1, + path: "P.md", + content: "v21 content (D2)\n" + }, + { type: "sync", client: 1 }, + + { type: "resume-websocket", client: 0 }, + + { + type: "update", + client: 0, + path: "P.md", + content: "local edit by client 0\n" + }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("P.md", "v21 content (D2)\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts new file mode 100644 index 00000000..b78ad143 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/remote-update-survives-user-rename.test.ts @@ -0,0 +1,84 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const remoteUpdateSurvivesUserRenameTest: TestDefinition = { + description: + "Client 0 updates a tracked doc; while Client 1 is processing the " + + "broadcast and parked on the GET for the new version's content, the " + + "user renames the doc on Client 1. Pre-fix: `processRemoteUpdate` " + + "captures `actualPath` before the await and, after the GET returns, " + + "calls `write(actualPath, …)` (no-op — file was renamed away), " + + "`updateCache(actualPath, …)`, and `setDocument(actualPath, …)`. " + + "`setDocument` mutates the same record in place so its `path` is " + + "yanked from the user's renamed slot back to the pre-rename path, " + + "wiping the rename out of the queue's documents map. The queued " + + "`LocalUpdate` then reads from the now-stale `record.path`, hits " + + "`FileNotFoundError`, and is silently dropped — the user's rename " + + "never reaches the server. Post-fix: the handler defers when a " + + "local event landed mid-await, so the rename drains first and " + + "the deferred remote update is folded into the broadcast that " + + "follows the rename round-trip.", + 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" }, + + // Buffer Client 1's incoming broadcasts so it doesn't see + // Client 0's update until we've paused the server. + { type: "pause-websocket", client: 1 }, + + // Server now holds v=2 of doc.md. + { type: "update", client: 0, path: "doc.md", content: "v2\n" }, + { type: "sync", client: 0 }, + + // Pause the server. Client 1's upcoming GET for the new version + // content blocks at the OS layer until resume. + { type: "pause-server" }, + + // Release the buffered broadcast. Client 1's drain enters + // `processRemoteUpdate`, captures `actualPath`, fires the GET, + // and parks awaiting the response. + { type: "resume-websocket", client: 1 }, + + // Yield long enough for the drain to traverse all microtask + // hops between the WS handler and the GET, so the HTTP request + // is queued at the (paused) server before the rename runs. + // Without this yield the rename would be enqueued before + // `processRemoteUpdate`'s entry-time `hasPendingLocalEvents` + // check and the early-defer branch would mask the bug. + { type: "sleep", ms: 50 }, + + // While the GET is in flight the user renames the doc. The queue + // mutates `record.path` to "renamed.md" in place and pushes a + // LocalUpdate carrying the rename target. + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + // Resume the server. The GET response unblocks + // `processRemoteUpdate`. With the fix in place it sees the + // queued LocalUpdate and defers; without the fix it walks past + // the rename and clobbers the documents map, dropping the + // pending LocalUpdate's read on the way back through. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1); + s.assertFileExists("renamed.md"); + s.assertFileNotExists("doc.md"); + // Both edits survive: the user's rename and Client 0's + // content update at v=2. + s.assertContent("renamed.md", "v2\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts new file mode 100644 index 00000000..822e83df --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain-during-pending-create.test.ts @@ -0,0 +1,64 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainDuringPendingCreateTest: TestDefinition = { + description: + "User creates a doc, then renames it twice while the LocalCreate's " + + "HTTP roundtrip is still in flight (server paused). Each rename " + + "pushes a LocalUpdate whose `documentId` is the create's Promise " + + "(see `pendingDocumentId` in `SyncEventQueue.enqueue`). After the " + + "create resolves, the first rename drains successfully and " + + "`setDocument` walks `events[]` to retarget queued LocalUpdates' " + + "`event.path` to the new disk location — but the comparison " + + "`e.documentId === record.documentId` mismatches the still-Promise " + + "references, so the second rename's `event.path` stays at the " + + "vacated previous slot. On the next drain step `skipIfOversized`'s " + + "`getFileSize(event.path)` throws FileNotFoundError, which " + + "`processEvent` swallows as 'Skipping sync event ... because the " + + "file no longer exists' — losing the user's final rename. " + + "Post-fix: `resolveCreate` (and the displacement-merge branch in " + + "`processCreate`) swap the Promise references for the resolved id " + + "before `setDocument` runs, so retarget works.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause the server so client 0's create stalls on the HTTP PUT + // while we queue rename events behind it. + { type: "pause-server" }, + + { type: "create", client: 0, path: "first.md", content: "v1\n" }, + { + type: "rename", + client: 0, + oldPath: "first.md", + newPath: "second.md" + }, + { + type: "rename", + client: 0, + oldPath: "second.md", + newPath: "third.md" + }, + + // Resume — drain pops LocalCreate (now resolves), then the two + // queued LocalUpdates. Pre-fix: only the first rename's + // file-system effect lands; the second is silently dropped. + // The server ends up with the doc at second.md, leaving + // client 0's local third.md untracked / out-of-sync. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("third.md"); + state.assertContent("third.md", "v1\n"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts new file mode 100644 index 00000000..03196919 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -0,0 +1,50 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainThenDeleteTest: TestDefinition = { + description: + "Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " + + "After client 1 reconnects, both clients must have no files.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "X.md", content: "chain-content" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("X.md", "chain-content"); + } + }, + + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 0, + oldPath: "X.md", + newPath: "Y.md" + }, + { type: "sync", client: 0 }, + { + type: "rename", + client: 0, + oldPath: "Y.md", + newPath: "Z.md" + }, + { type: "sync", client: 0 }, + { type: "delete", client: 0, path: "Z.md" }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts new file mode 100644 index 00000000..8f9d7a7f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameChainTest: TestDefinition = { + description: + "Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " + + "When sync is enabled, only C.md should exist. Client 1 should receive C.md " + + "with the original content. Intermediate paths should never appear.", + clients: 2, + steps: [ + { type: "enable-sync", client: 1 }, + + { + type: "create", + client: 0, + path: "A.md", + content: "important content" + }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "important content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts new file mode 100644 index 00000000..44a65149 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameCircularTest: TestDefinition = { + description: + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.", + 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: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "content-a") + .assertContent("B.md", "content-b") + .assertContent("C.md", "content-c"); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, + { type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, + { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp-a.md") + .assertFileCount(3) + .assertAnyFileContains("content-c") + .assertAnyFileContains("content-a") + .assertAnyFileContains("content-b"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts new file mode 100644 index 00000000..fc6a00a7 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -0,0 +1,34 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameCreateConflictTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "create", client: 0, path: "A.md", content: "hi" }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "hi"); + } + }, + { type: "disable-sync", client: 0 }, + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 1 }, + { type: "create", client: 0, path: "B.md", content: "hi" }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertContent("B.md", "hi") + .assertContent("B (1).md", "hi"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts new file mode 100644 index 00000000..0b47c781 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-overwrites-pending-create-then-delete.test.ts @@ -0,0 +1,51 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameOverwritesPendingCreateThenDeleteTest: TestDefinition = { + description: + "A pending local create at a path must not mask a synced document renamed onto that path; later rename/delete events still belong to the synced document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "tracked.bin", + content: "BINARY:tracked" + }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "pending.bin", + content: "BINARY:pending" + }, + { + type: "rename", + client: 0, + oldPath: "tracked.bin", + newPath: "pending.bin" + }, + { + type: "rename", + client: 0, + oldPath: "pending.bin", + newPath: "final.bin" + }, + { type: "delete", client: 0, path: "final.bin" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts new file mode 100644 index 00000000..26623c43 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamePendingCreateBeforeResponseTest: TestDefinition = { + description: + "Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "original-content" + }, + + { + type: "rename", + client: 0, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "renamed.md", + "original-content" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts new file mode 100644 index 00000000..0906f209 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-onto-pending-delete-path.test.ts @@ -0,0 +1,59 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamePendingCreateOntoPendingDeletePathTest: TestDefinition = { + description: + "A pending create is renamed onto a path whose old server document " + + "has a queued delete. The delete must reach the server before the " + + "new create so the new generation is not merged into the soon-to-be " + + "deleted document.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "create", + client: 1, + path: "file-17.md", + content: "old\n" + }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.md", + content: "blocker\n" + }, + { type: "sleep", ms: 100 }, + { + type: "create", + client: 1, + path: "file-23.md", + content: "new\n" + }, + { type: "delete", client: 1, path: "file-17.md" }, + { + type: "rename", + client: 1, + oldPath: "file-23.md", + newPath: "file-17.md" + }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.md", "blocker\n") + .assertContent("file-17.md", "new\n") + .assertFileNotExists("file-23.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts new file mode 100644 index 00000000..0373debf --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -0,0 +1,40 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameRoundtripTest: TestDefinition = { + description: + "Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.", + 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: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContent("B.md", "original"); + } + }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContent("A.md", "original"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts new file mode 100644 index 00000000..9910e8ef --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameSwapTest: TestDefinition = { + description: + "Client 0 has A.md and B.md synced. Goes offline and swaps them using " + + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + + "When Client 0 reconnects, both contents should exist across two files.", + 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.assertContent("A.md", "content-a").assertContent( + "B.md", + "content-b" + ); + } + }, + + { type: "disable-sync", client: 0 }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp.md") + .assertFileCount(2) + .assertAnyFileContains("content-b") + .assertAnyFileContains("content-a"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts new file mode 100644 index 00000000..34a3867c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -0,0 +1,44 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { + description: + "Client 0 deletes A.md then renames B.md to A.md. After syncing, " + + "B's content should exist and the old A.md content should be gone. " + + "The server may deconflict the path if the delete and move arrive " + + "in the same transaction.", + 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: "sync" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "content B" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts new file mode 100644 index 00000000..8747218a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameToPendingPathFallbackTest: TestDefinition = { + description: + "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "B.md", + content: "tracked B content" + }, + { 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: "pending A content" + }, + + { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "tracked B content" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts new file mode 100644 index 00000000..18d4c101 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renameUpdateConflictTest: TestDefinition = { + description: + "Client 0 renames A.md to B.md while client 1 updates A.md offline. After client 1 reconnects, both should converge with the update at B.md.", + 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: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 0 }, + + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContains("B.md", "updated"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts new file mode 100644 index 00000000..3ffb376e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/renamed-pending-create-reused-path-then-delete.test.ts @@ -0,0 +1,65 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const renamedPendingCreateReusedPathThenDeleteTest: TestDefinition = { + description: + "A queued create is renamed away from file-59.md, a newer local " + + "file reuses file-59.md before the queued create drains, and the " + + "renamed-away generation is deleted. The delete must not erase or " + + "orphan the newer file-59.md generation.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + { + type: "create", + client: 1, + path: "blocker.md", + content: "blocker\n" + }, + { type: "sleep", ms: 100 }, + + { + type: "create", + client: 1, + path: "file-59.md", + content: "old\n" + }, + { + type: "rename", + client: 1, + oldPath: "file-59.md", + newPath: "file-33.md" + }, + { + type: "create", + client: 1, + path: "file-59.md", + content: "new\n" + }, + + { + type: "resume-server-until-history-then-pause", + client: 1, + syncType: "CREATE", + path: "file-33.md" + }, + { type: "delete", client: 1, path: "file-33.md" }, + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) + .assertContent("blocker.md", "blocker\n") + .assertContent("file-59.md", "new\n") + .assertFileNotExists("file-33.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts new file mode 100644 index 00000000..e0a1565c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { + description: + "Client 0 deletes a file. Client 1 toggles sync off and on " + + "(simulating reconnect). The deleted file should NOT reappear " + + "on Client 1 after the sync reset.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "ghost.md", + content: "should be deleted" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "ghost.md" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("ghost.md"); + } + }, + + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 1 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts new file mode 100644 index 00000000..2a3b5de4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-after-remote-quick-write-and-pending-rename.test.ts @@ -0,0 +1,82 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseAfterRemoteQuickWriteAndPendingRenameTest: TestDefinition = + { + description: + "A remote create starts quick-writing at doc.md while a local " + + "create for the same path is queued and renamed to renamed.md. " + + "Because the local create was renamed before it reached the " + + "server, the two generations should remain separate tracked " + + "documents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + // Create a deleted latest version before client 1 joins. + // Catch-up will advance MinCovered with a non-contiguous id, + // keeping client 1's create lastSeen low enough to exercise + // the server's same-doc merge path from the e2e failure. + { + type: "create", + client: 0, + path: "history.md", + content: "history-v1" + }, + { type: "sync", client: 0 }, + { + type: "update", + client: 0, + path: "history.md", + content: "history-v2" + }, + { type: "sync", client: 0 }, + { type: "delete", client: 0, path: "history.md" }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "pause-websocket", client: 1 }, + + { + type: "create", + client: 0, + path: "doc.md", + content: "remote\n" + }, + { type: "sync", client: 0 }, + + // Let client 1's buffered RemoteCreate enter the quick-write + // path, but hold the content fetch until the local create has + // appeared and moved away from doc.md. + { type: "pause-server" }, + { type: "resume-websocket", client: 1 }, + { type: "sleep", ms: 100 }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "local\n" + }, + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "renamed.md" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(2); + state.assertContent("doc.md", "remote\n"); + state.assertContent("renamed.md", "local\n"); + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts new file mode 100644 index 00000000..dee3a9ad --- /dev/null +++ b/frontend/deterministic-tests/src/tests/same-doc-id-collapse-on-local-create-after-remote-create.test.ts @@ -0,0 +1,121 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition = + { + description: + "Client B creates X with content C2; the server commits and " + + "broadcasts. Client A's WS is paused so the RemoteCreate buffers. " + + "Server is then paused so A's about-to-POST LocalCreate will " + + "hang. A creates X with content C1: file lands on disk, " + + "LocalCreate enqueues, drain starts the POST, the POST stalls " + + "at the paused server. A's WS is resumed: the buffered " + + "RemoteCreate for doc-X is delivered to A and enqueues behind " + + "the in-flight LocalCreate. Per the lazy-paths model, when " + + "the RemoteCreate is processed it observes that path X is " + + "occupied locally by A's pending-create bytes, so it tracks " + + "doc-X with `localPath = undefined` / `remoteRelativePath = " + + "X` and does NOT fetch content. The server is then resumed: " + + "A's LocalCreate POST returns. The server, finding X already " + + "taken by doc-X, replies with doc-X's existing documentId " + + "(typically a MergingUpdate carrying the merged bytes). A's " + + "processCreate handler detects that response.documentId " + + "matches the no-localPath record built from the RemoteCreate " + + "and collapses the two: it sets localPath = X on that " + + "record, writes the merged bytes, and resolves the pending " + + "create promise. Final state: exactly one file at X on both " + + "clients, both pointing at doc-X's documentId, content " + + "carrying both contributions, and no conflict-- " + + "stash anywhere.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Buffer broadcasts to client 0 (A) so client 1's create + // doesn't reach A's WS handler until we say so. + { type: "pause-websocket", client: 0 }, + + // Client 1 (B) commits doc-X at path X with content C2. + // The server commits, broadcasts (broadcast queued at A's + // paused WS). + { + type: "create", + client: 1, + path: "X.md", + content: "from-client-1 " + }, + { type: "sync", client: 1 }, + + // Pause the server so A's upcoming LocalCreate POST hangs. + // This holds A's drain on the in-flight POST while we + // release the WS so the RemoteCreate enqueues behind it. + { type: "pause-server" }, + + // Client 0 (A) creates X locally with content C1. The + // file lands on A's disk; LocalCreate enqueues; drain + // starts the POST; POST stalls at the paused server. + { + type: "create", + client: 0, + path: "X.md", + content: "from-client-0 " + }, + + // Release A's WS. The buffered RemoteCreate for doc-X is + // delivered to A and enqueues behind the in-flight + // LocalCreate. Whichever of (RemoteCreate processed first + // → no-localPath record, then LocalCreate POST returns + // with merging response that collapses) or (LocalCreate + // POST returns first with merging response that creates + // the canonical record, then RemoteCreate finds the doc + // already tracked by id and no-ops) actually plays out + // depends on the fine-grained interleaving the runtime + // produces, but both paths are required to converge to + // the same single-record same-docId state. + { type: "resume-websocket", client: 0 }, + + // Resume the server: A's LocalCreate POST completes. + // Server returns doc-X's existing documentId (MergingUpdate + // with merged content). processCreate runs the collapse + // path. + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(1); + state.assertFileExists("X.md"); + // Server-side merge of the two text creates must + // carry both contributions through to the + // converged file. + state.assertContains( + "X.md", + "from-client-0", + "from-client-1" + ); + // The lazy-paths collapse path must not leave a + // conflict-- stash on either client. + for (const path of state.files.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a converged client: ${path}` + ); + } + } + for (const perClient of state.clientFiles) { + for (const path of perClient.keys()) { + if (path.startsWith("conflict-")) { + throw new Error( + `Unexpected conflict-uuid stash on a per-client view: ${path}` + ); + } + } + } + } + } + ] + }; diff --git a/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts new file mode 100644 index 00000000..ac8ed3ed --- /dev/null +++ b/frontend/deterministic-tests/src/tests/self-merge-pending-rename-aliases-second-create.test.ts @@ -0,0 +1,152 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const selfMergePendingRenameAliasesSecondCreateTest: TestDefinition = { + description: + "Single client makes two distinct creates that briefly share a path. " + + "Client 0 POSTs the first create at primary.md while the server is " + + "paused. While that POST is in flight: a second create is queued at " + + "staging.md, primary.md is renamed to moved.md (rewriting the in- " + + "flight create's event.path to moved.md and pushing a rename " + + "LocalUpdate at the queue tail), and staging.md is renamed onto the " + + "now-vacated primary.md slot (rewriting the second create's " + + "event.path to primary.md and pushing another rename LocalUpdate). " + + "Client 0's WS is paused throughout, so its watermark stays at 0. " + + "On resume the first POST commits Doc-X at primary.md (creation_vuid " + + "= N). The drain then processes the second LocalCreate (POST " + + "relativePath=primary.md, last_seen=0); the server's path-based " + + "dedup sees N > 0 and merges the second create into Doc-X " + + "(MergingUpdate). The buggy behaviour: processCreate's resolveCreate " + + "calls upsertRecord with localPath=primary.md, but the existing " + + "record (from the first create) already holds localPath=moved.md, " + + "and upsertRecord's `existing.localPath !== undefined` guard " + + "silently drops the new claim. The file at primary.md is left " + + "orphaned: tracked by no record, never broadcast, never deleted. " + + "After the user's renames the expected user-visible state is two " + + "distinct files at moved.md and primary.md — both clients must " + + "converge to that.", + clients: 2, + steps: [ + // Both clients online so the WS connection is established before + // the test starts pausing things. + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + // Pause client 0's WS so its MinCovered watermark stays at 0 + // through the whole bug sequence. The merge condition the + // server is going to fire is `creation_vuid > last_seen`; with + // a non-zero gap the same-device second create gets merged + // into the same-device first create. + { type: "pause-websocket", client: 0 }, + + // Client 1 commits a doc to push the server's vuid above 0. + // Without this filler, Doc-X's create vuid could be 1 and + // client 0's last_seen.add(1) would advance min to 1, killing + // the watermark gap that triggers the merge. + { + type: "create", + client: 1, + path: "filler.md", + content: "filler-content " + }, + { type: "sync", client: 1 }, + + // Pause the server so client 0's first create POST hangs in + // flight, giving us a deterministic window in which to enqueue + // the second create and the renames. + { type: "pause-server" }, + + // First create — Doc-X. The wire-loop drains it, captures + // requestPath = event.path = "primary.md", reads the bytes, + // sends the POST, and stalls on the response. + { + type: "create", + client: 0, + path: "primary.md", + content: "primary content " + }, + + // Make sure the POST is actually on the wire with + // relativePath="primary.md" before we rewrite event.path. + // Without this delay the rename can win the race, the POST + // goes out with relativePath="moved.md", and the server-side + // path-collision merge never fires. + { type: "sleep", ms: 100 }, + + // Second create at a staging path. The wire-loop is still + // blocked on Doc-X's POST, so this LocalCreate just queues at + // index 1. + { + type: "create", + client: 0, + path: "staging.md", + content: "secondary content " + }, + + // Rename Doc-X's path. enqueue's pending-create branch + // rewrites Doc-X's event.path in place (moved.md) and pushes + // a LocalUpdate(rename, originalPath=moved.md) at the END of + // the queue. Note the ordering: this LocalUpdate is enqueued + // AFTER the staging LocalCreate above. That ordering is + // load-bearing — it is what makes the second create's POST + // drain (and trigger the server-side merge) before Doc-X's + // rename PUT moves the doc away from primary.md on the + // server. + { + type: "rename", + client: 0, + oldPath: "primary.md", + newPath: "moved.md" + }, + + // Rename the staging file onto Doc-X's now-vacated primary.md + // slot. enqueue rewrites the staging LocalCreate's event.path + // to primary.md and pushes a LocalUpdate(rename, + // originalPath=primary.md) at the queue tail. After this the + // disk has: moved.md = Doc-X's bytes, primary.md = Doc-Y's + // bytes. + { + type: "rename", + client: 0, + oldPath: "staging.md", + newPath: "primary.md" + }, + + // Let everything fly: server processes the queued POSTs; + // client 0 catches up on broadcasts. + { type: "resume-server" }, + { type: "resume-websocket", client: 0 }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + // The user did two distinct creates (Doc-X and Doc-Y); + // both contents must survive on both clients. + state.assertFileCount(3); + state.assertFileExists("filler.md"); + state.assertFileExists("moved.md"); + state.assertFileExists("primary.md"); + + // After the renames the user expects: + // - moved.md = the file that was originally created + // at primary.md (Doc-X's content). + // - primary.md = the file that was originally created + // at staging.md (Doc-Y's content). + state.assertContains("moved.md", "primary content"); + state.assertContains("primary.md", "secondary content"); + + // No content cross-contamination: each contribution + // should land in exactly one of the user-visible + // files. Under the bug, the orphan at primary.md + // carries Doc-X's content (because Doc-Y's PUT was + // aliased onto Doc-X's record and read Doc-X's bytes + // from moved.md), so this catches the leak too. + state.assertContentInAtMostOneFile("primary content"); + state.assertContentInAtMostOneFile("secondary content"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts new file mode 100644 index 00000000..611e1ae3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -0,0 +1,43 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const sequentialCreateDuplicateContentTest: TestDefinition = { + description: + "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "A.md", + content: "identical content here" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "identical content here"); + } + }, + + { + type: "create", + client: 0, + path: "B.md", + content: "identical content here" + }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertContent("A.md", "identical content here") + .assertContent("B.md", "identical content here"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts new file mode 100644 index 00000000..f99cf92d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseBothClientsCreateTest: TestDefinition = { + description: + "Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "create", + client: 0, + path: "alpha.md", + content: "from client 0" + }, + { type: "pause-server" }, + + { + type: "create", + client: 1, + path: "beta.md", + content: "from client 1" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContains("alpha.md", "from client 0").assertContains( + "beta.md", + "from client 1" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts new file mode 100644 index 00000000..ff8cf194 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -0,0 +1,68 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseBothEditSameFileTest: TestDefinition = { + description: + "Both clients edit different sections of the same file while the server is paused. After resuming and converging, client 0 makes another edit to verify further updates still work correctly.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "shared.md", + content: "line 1: original\nline 2: original\nline 3: original" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "update", + client: 0, + path: "shared.md", + content: + "line 1: edited by client 0\nline 2: original\nline 3: original" + }, + { + type: "update", + client: 1, + path: "shared.md", + content: + "line 1: original\nline 2: original\nline 3: edited by client 1" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "edited by client 0", + "edited by client 1" + ); + } + }, + + { + type: "update", + client: 0, + path: "shared.md", + content: "post-merge edit from client 0" + }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "post-merge edit from client 0" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts new file mode 100644 index 00000000..5ac97f0d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseDeleteRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and syncs. The server is paused, then client 0 creates at the same path. After the server resumes, both clients should have the recreated 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: "pause-server" }, + + { + type: "create", + client: 0, + path: "A.md", + content: "recreated during contention" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("A.md", "recreated during contention"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts new file mode 100644 index 00000000..b1739135 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -0,0 +1,50 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseRenameEditResumeTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs. Server is paused. Client 0 " + + "renames A.md to B.md and edits B.md. Server resumes. Both the " + + "rename and edit should propagate to Client 1.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "A.md", + content: "original content" + }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original content"); + } + }, + + { type: "pause-server" }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edited after rename during pause" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContent("B.md", "edited after rename during pause"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts new file mode 100644 index 00000000..2389ccf5 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -0,0 +1,54 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const serverPauseUpdateAndCreateTest: TestDefinition = { + description: + "Client 0 updates a shared file while client 1 creates a new file, both during a server pause. After the server resumes, both operations should complete and propagate to both clients.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { + type: "create", + client: 0, + path: "shared.md", + content: "initial content" + }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("shared.md", "initial content"); + } + }, + + { type: "pause-server" }, + + { + type: "update", + client: 0, + path: "shared.md", + content: "updated during pause" + }, + { + type: "create", + client: 1, + path: "new-file.md", + content: "created by client 1" + }, + + { type: "resume-server" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent( + "shared.md", + "updated during pause" + ).assertContent("new-file.md", "created by client 1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts new file mode 100644 index 00000000..7ec116ac --- /dev/null +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -0,0 +1,38 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const simultaneousCreateDeleteSamePathTest: TestDefinition = { + description: + "Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " + + "Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " + + "the update and delete must be reconciled. Both clients must converge.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original from 0" }, + { 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: "A.md", + content: "modified by 1 while offline" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts new file mode 100644 index 00000000..28243525 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const textPendingCreateNotDisplacedTest: TestDefinition = { + description: + "Two clients each create a text file at the same path while offline. " + + "After syncing, the file should contain merged content from both clients.", + clients: 2, + steps: [ + { + type: "create", + client: 0, + path: "data.txt", + content: "text data from client-0" + }, + { + type: "create", + client: 1, + path: "data.txt", + content: "text 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(1) + .assertFileExists("data.txt") + .assertAnyFileContains("client-0", "client-1"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts new file mode 100644 index 00000000..80478adc --- /dev/null +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -0,0 +1,55 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const threeClientRenameCreateDeleteTest: TestDefinition = { + description: + "Client 0 renames X -> Y, Client 1 deletes X, Client 2 creates Y. " + + "All three operations happen while the other clients are offline. " + + "Tests that the system handles the three-way conflict and converges.", + clients: 3, + steps: [ + { + type: "create", + client: 0, + path: "X.md", + content: "original from A" + }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "disable-sync", client: 2 }, + + { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, + + { type: "delete", client: 1, path: "X.md" }, + + { + type: "create", + client: 2, + path: "Y.md", + content: "new from C" + }, + + { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, + + { type: "enable-sync", client: 2 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("X.md").assertAnyFileContains( + "new from C" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts new file mode 100644 index 00000000..70a2fc8c --- /dev/null +++ b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts @@ -0,0 +1,36 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { + description: + "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.", + 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: "delete", client: 0, path: "doc.md" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "edited by client 1" + }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts new file mode 100644 index 00000000..ca53244e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -0,0 +1,42 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const updateDuringCreateProcessingTest: TestDefinition = { + description: + "Client 0 creates a file while the server is paused, then immediately updates it. After the server resumes, both clients should converge with the updated content.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "pause-server" }, + + { + type: "create", + client: 0, + path: "file.md", + content: "initial" + }, + + { + type: "update", + client: 0, + path: "file.md", + content: "updated during create" + }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "file.md", + "updated during create" + ); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts new file mode 100644 index 00000000..ef6cd771 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts @@ -0,0 +1,47 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const userParenthesizedFileNotDeletedTest: TestDefinition = { + description: + "A user-created file named 'Chapter (1).bin' alongside 'Chapter.bin' should not " + + "be mistakenly removed when another client creates a conflicting file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "Chapter.bin", + content: "chapter one" + }, + { + type: "create", + client: 0, + path: "Chapter (1).bin", + content: "chapter one notes" + }, + + { type: "sync", client: 0 }, + + { + type: "create", + client: 1, + path: "Chapter.bin", + content: "chapter one notes" + }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(3) + .assertFileExists("Chapter.bin") + .assertFileExists("Chapter (1).bin") + .assertFileExists("Chapter (2).bin"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts new file mode 100644 index 00000000..063faff4 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -0,0 +1,35 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const watermarkAdvancesOnSkipTest: TestDefinition = { + description: + "Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "create", client: 0, path: "doc.md", content: "from client 0" }, + { type: "create", client: 1, path: "doc.md", content: "from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { 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).assertFileExists("doc.md"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts new file mode 100644 index 00000000..ac9ba467 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -0,0 +1,37 @@ +import type { AssertableState } from "../utils/assertable-state"; +import type { TestDefinition } from "../test-definition"; + +export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { + description: + "Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after 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: "sync", client: 0 }, + { type: "update", client: 0, path: "doc.md", content: "update 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } + }, + + { type: "disable-sync", client: 1 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } + } + ] +}; diff --git a/frontend/deterministic-tests/src/utils/assert.ts b/frontend/deterministic-tests/src/utils/assert.ts new file mode 100644 index 00000000..4e709060 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assert.ts @@ -0,0 +1,5 @@ +export function assert(value: boolean, message: string): asserts value { + if (!value) { + throw new Error(message); + } +} diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts new file mode 100644 index 00000000..7c6f192c --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -0,0 +1,150 @@ +import type { ClientState } from "../test-definition"; + +export class AssertableState { + public readonly files: Map; + public readonly clientFiles: Map[]; + + public constructor(state: ClientState) { + this.files = state.files; + this.clientFiles = state.clientFiles; + } + + public assertFileCount(expected: number): this { + if (this.files.size !== expected) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected ${expected} file(s), got ${this.files.size}: [${keys}]` + ); + } + return this; + } + + public assertFileExists(path: string): this { + if (!this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error(`Expected "${path}" to exist. Files: [${keys}]`); + } + return this; + } + + public assertFileNotExists(path: string): this { + if (this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected "${path}" not to exist. Files: [${keys}]` + ); + } + return this; + } + + public assertContent(path: string, expected: string): this { + this.assertFileExists(path); + const actual = this.files.get(path) ?? ""; + if (actual !== expected) { + throw new Error( + `Expected "${path}" to have content "${expected}", got: "${actual}"` + ); + } + return this; + } + + public assertContains(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const missing = substrings.filter((s) => !content.includes(s)); + if (missing.length > 0) { + throw new Error( + `Expected "${path}" to contain ${missing.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + public assertContainsAny(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const found = substrings.some((s) => content.includes(s)); + if (!found) { + throw new Error( + `Expected "${path}" to contain at least one of ${substrings.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + public assertAnyFileContains(...substrings: string[]): this { + const allContent = Array.from(this.files.values()).join("\n"); + const missing = substrings.filter((s) => !allContent.includes(s)); + if (missing.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected some file to contain ${missing.map((s) => `"${s}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + public assertNoFileContains(...substrings: string[]): this { + const offenders: { path: string; substring: string }[] = []; + for (const [path, content] of this.files) { + for (const s of substrings) { + if (content.includes(s)) { + offenders.push({ path, substring: s }); + } + } + } + if (offenders.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected no file to contain ${substrings.map((s) => `"${s}"`).join(", ")}, but found ${offenders.map((o) => `"${o.substring}" in "${o.path}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + public assertSubstringCount( + path: string, + substring: string, + expected: number + ): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const actual = content.split(substring).length - 1; + if (actual !== expected) { + throw new Error( + `Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"` + ); + } + return this; + } + + public assertContentInAtMostOneFile(substring: string): this { + const matches = Array.from(this.files.entries()).filter(([, content]) => + content.includes(substring) + ); + if (matches.length > 1) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected "${substring}" in at most 1 file, found in ${matches.length}: [${matches.map(([p]) => p).join(", ")}].\nFiles:\n${dump}` + ); + } + return this; + } + + public ifFileExists(path: string, fn: (state: this) => void): this { + if (this.files.has(path)) { + fn(this); + } + return this; + } + + public getContent(path: string): string { + return this.files.get(path) ?? ""; + } +} diff --git a/frontend/deterministic-tests/src/utils/find-free-port.ts b/frontend/deterministic-tests/src/utils/find-free-port.ts new file mode 100644 index 00000000..0734c1a9 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/find-free-port.ts @@ -0,0 +1,29 @@ +import * as net from "node:net"; + +interface PortReservation { + port: number; + release: () => void; +} + +/** + * Find a free port and keep it reserved until the caller explicitly releases it. + */ +export async function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + server.close(); + reject(new Error("Failed to get port from server")); + return; + } + const { port } = addr; + resolve({ + port, + release: () => server.close() + }); + }); + server.on("error", reject); + }); +} diff --git a/frontend/deterministic-tests/src/utils/sleep.ts b/frontend/deterministic-tests/src/utils/sleep.ts new file mode 100644 index 00000000..ff474799 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/deterministic-tests/src/utils/with-timeout.ts b/frontend/deterministic-tests/src/utils/with-timeout.ts new file mode 100644 index 00000000..14ee3f27 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/with-timeout.ts @@ -0,0 +1,15 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string +): Promise { + let timeoutId: ReturnType | undefined = undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(message)); + }, timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timeoutId); + }); +} diff --git a/frontend/deterministic-tests/tsconfig.json b/frontend/deterministic-tests/tsconfig.json new file mode 100644 index 00000000..7558871d --- /dev/null +++ b/frontend/deterministic-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "strict": true, + "target": "ES2022", + "module": "CommonJS", + "esModuleInterop": true, + "lib": ["DOM", "ES2024"], + "moduleResolution": "node" + }, + "exclude": ["./dist"] +} diff --git a/frontend/deterministic-tests/webpack.config.js b/frontend/deterministic-tests/webpack.config.js new file mode 100644 index 00000000..6aee1547 --- /dev/null +++ b/frontend/deterministic-tests/webpack.config.js @@ -0,0 +1,30 @@ +const path = require("path"); +const webpack = require("webpack"); + +module.exports = { + entry: "./src/cli.ts", + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } + ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "cli.js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] +}; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index db648d46..1e33ac41 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -3,60 +3,92 @@ import tseslint from "typescript-eslint"; import unusedImports from "eslint-plugin-unused-imports"; export default [ - { - ignores: [ - "sync-client/src/services/types.ts", - "**/dist/", - "**/*.mjs", - "**/*.js" - ] - }, - ...tseslint.config({ - plugins: { - "unused-imports": unusedImports - }, - extends: [eslint.configs.recommended, tseslint.configs.all], - rules: { - "no-unused-vars": "off", - "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/no-floating-promises": [ - "error", - { - allowForKnownSafeCalls: [ - { from: "package", name: ["suite", "test"], package: "node:test" }, - ], - }, - ], - "@typescript-eslint/parameter-properties": "off", - "@typescript-eslint/require-await": "off", - "@typescript-eslint/class-methods-use-this": "off", - "@typescript-eslint/consistent-return": "off", - "@typescript-eslint/no-unsafe-argument": "off", - "@typescript-eslint/max-params": [ - "error", - { - max: 6 - } - ], - "@typescript-eslint/no-magic-numbers": "off", - "@typescript-eslint/prefer-readonly-parameter-types": "off", - "@typescript-eslint/naming-convention": "off", - "unused-imports/no-unused-vars": [ - "warn", - { - vars: "all", - varsIgnorePattern: "^_", - args: "after-used", - argsIgnorePattern: "^_" - } - ] - }, - languageOptions: { - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname - } - } - }) + { + ignores: [ + "sync-client/src/services/types.ts", + "**/dist/", + "**/*.mjs", + "**/*.js" + ] + }, + ...tseslint.config({ + plugins: { + "unused-imports": unusedImports + }, + extends: [eslint.configs.recommended, tseslint.configs.all], + rules: { + "no-unused-vars": "off", + "@typescript-eslint/restrict-template-expressions": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-floating-promises": [ + "error", + { + allowForKnownSafeCalls: [ + { from: "package", name: ["suite", "test"], package: "node:test" }, + ], + }, + ], + "@typescript-eslint/parameter-properties": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/class-methods-use-this": "off", + "@typescript-eslint/consistent-return": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/max-params": "off", + "@typescript-eslint/no-magic-numbers": "off", + "@typescript-eslint/prefer-readonly-parameter-types": "off", + "@typescript-eslint/naming-convention": "off", + "no-restricted-properties": [ + "error", + { + object: "Promise", + property: "all", + message: "Use `awaitAll` instead of Promise.all to always await all promises." + }, + { + object: "Promise", + property: "allSettled", + message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors." + }, + { + object: "String", + property: "replace", + message: "Use replaceAll instead of replace to replace all occurrences of a substring." + } + ], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]", + message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + } + ], + "unused-imports/no-unused-vars": [ + "warn", + { + vars: "all", + varsIgnorePattern: "^_", + args: "after-used", + argsIgnorePattern: "^_" + } + ] + }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + } + } + }) ]; diff --git a/frontend/local-client-cli/Dockerfile b/frontend/local-client-cli/Dockerfile index 6b8e1d6c..0dfa7055 100644 --- a/frontend/local-client-cli/Dockerfile +++ b/frontend/local-client-cli/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim AS builder +FROM node:25-slim AS builder WORKDIR /build @@ -7,7 +7,7 @@ COPY . . RUN npm ci RUN npm run build -FROM node:22-alpine +FROM node:25-alpine LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" @@ -16,10 +16,14 @@ LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="andras@schmelczer.dev" COPY --from=builder /build/local-client-cli/dist/cli.js /app/cli.js +COPY --from=builder /build/local-client-cli/dist/healthcheck.js /app/healthcheck.js + +HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=1 \ + CMD node /app/healthcheck.js /tmp/vaultlink-health.json WORKDIR /vault VOLUME ["/vault"] -ENTRYPOINT ["node", "/app/cli.js"] +ENTRYPOINT ["node", "/app/cli.js", "--health", "/tmp/vaultlink-health.json"] CMD ["--help"] diff --git a/frontend/local-client-cli/README.md b/frontend/local-client-cli/README.md index 0585bacc..e91322f9 100644 --- a/frontend/local-client-cli/README.md +++ b/frontend/local-client-cli/README.md @@ -47,24 +47,25 @@ vaultlink \ ### Required -| Option | Description | -|--------|-------------| -| `-l, --local-path ` | Local directory to sync | -| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | -| `-t, --token ` | Authentication token | -| `-v, --vault-name ` | Vault name on server | +| Option | Description | +| ------------------------- | --------------------------------------------- | +| `-l, --local-path ` | Local directory to sync | +| `-r, --remote-uri ` | Remote server WebSocket URI (ws:// or wss://) | +| `-t, --token ` | Authentication token | +| `-v, --vault-name ` | Vault name on server | ### Optional -| Option | Default | Description | -|--------|---------|-------------| -| `--sync-concurrency ` | `1` | Concurrent sync operations | -| `--max-file-size-mb ` | `10` | Maximum file size in MB | -| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | -| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | -| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | -| `-h, --help` | - | Show help | -| `-V, --version` | - | Show version | +| Option | Default | Description | +| ------------------------------------ | ------- | ----------------------------------------------- | +| `--max-file-size-mb ` | `10` | Maximum file size in MB | +| `--ignore-pattern ` | - | Glob pattern to ignore (repeatable) | +| `--websocket-retry-interval-ms ` | `3500` | WebSocket reconnection interval | +| `--log-level ` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | +| `--line-endings ` | `auto` | Line ending style: auto, lf, crlf | +| `-q, --quiet` | - | Suppress startup banner for non-interactive use | +| `-h, --help` | - | Show help | +| `-V, --version` | - | Show version | ### Auto-Ignored Patterns @@ -74,22 +75,32 @@ vaultlink \ ### Examples Basic usage: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default ``` With ignore patterns: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --ignore-pattern "*.tmp" \ + --ignore-pattern "**/*.tmp" \ --ignore-pattern ".DS_Store" \ --ignore-pattern "node_modules/**" ``` -With debug logging: +With debug logging and quiet startup: + ```bash vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ - --log-level DEBUG + --log-level DEBUG --quiet +``` + +Force LF line endings (useful for cross-platform vaults): + +```bash +vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ + --line-endings lf ``` ## Docker Deployment @@ -176,6 +187,7 @@ services: ## Development Build: + ```bash npm run build # or from the parent folder, run @@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile . ``` Test: + ```bash npm test ``` Docker build: + ```bash cd frontend docker build -f local-client-cli/Dockerfile -t vault-link-cli:test . diff --git a/frontend/local-client-cli/package.json b/frontend/local-client-cli/package.json index 183d308f..a862b297 100644 --- a/frontend/local-client-cli/package.json +++ b/frontend/local-client-cli/package.json @@ -1,27 +1,26 @@ { - "name": "local-client-cli", - "version": "0.9.2", - "description": "Standalone CLI for VaultLink sync client", - "private": false, - "bin": { - "vaultlink": "./dist/cli.js" - }, - "scripts": { - "dev": "webpack watch --mode development", - "build": "webpack --mode production", - "test": "tsx --test src/args.test.ts src/node-filesystem.test.ts" - }, - "dependencies": { - "commander": "^12.1.0" - }, - "devDependencies": { - "@types/node": "^24.8.1", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } + "name": "local-client-cli", + "version": "0.14.0", + "description": "Standalone CLI for VaultLink sync client", + "private": false, + "bin": { + "vaultlink": "./dist/cli.js" + }, + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "tsx --test 'src/**/*.test.ts'" + }, + "devDependencies": { + "commander": "^14.0.2", + "watcher": "^2.3.1", + "@types/node": "^25.0.2", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.4", + "tslib": "2.8.1", + "tsx": "^4.21.0", + "typescript": "5.9.3", + "webpack": "^5.103.0", + "webpack-cli": "^6.0.1" + } } diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index 206e39b7..fdf0b6c8 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -4,227 +4,210 @@ import { parseArgs } from "./args"; import { LogLevel } from "sync-client"; test("parseArgs - parse basic arguments", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); - assert.equal(args.localPath, "/path/to/vault"); - assert.equal(args.remoteUri, "https://sync.example.com"); - assert.equal(args.token, "mytoken"); - assert.equal(args.vaultName, "default"); + assert.equal(args.localPath, "/path/to/vault"); + assert.equal(args.remoteUri, "https://sync.example.com"); + assert.equal(args.token, "mytoken"); + assert.equal(args.vaultName, "default"); }); test("parseArgs - parse long form arguments", () => { - const args = parseArgs([ - "node", - "cli.js", - "--local-path", - "/path/to/vault", - "--remote-uri", - "https://sync.example.com", - "--token", - "mytoken", - "--vault-name", - "default" - ]); + const args = parseArgs([ + "node", + "cli.js", + "--local-path", + "/path/to/vault", + "--remote-uri", + "https://sync.example.com", + "--token", + "mytoken", + "--vault-name", + "default" + ]); - assert.equal(args.localPath, "/path/to/vault"); - assert.equal(args.remoteUri, "https://sync.example.com"); - assert.equal(args.token, "mytoken"); - assert.equal(args.vaultName, "default"); + assert.equal(args.localPath, "/path/to/vault"); + assert.equal(args.remoteUri, "https://sync.example.com"); + assert.equal(args.token, "mytoken"); + assert.equal(args.vaultName, "default"); }); test("parseArgs - parse with optional arguments", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--sync-concurrency", - "5", - "--max-file-size-mb", - "20" - ]); + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--max-file-size-mb", + "20" + ]); - assert.equal(args.syncConcurrency, 5); - assert.equal(args.maxFileSizeMB, 20); + assert.equal(args.maxFileSizeMB, 20); }); test("parseArgs - parse with multiple ignore patterns", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--ignore-pattern", - ".git/**", - "*.tmp" - ]); + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--ignore-pattern", + ".git/**", + "*.tmp" + ]); - assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]); + assert.deepEqual(args.ignorePatterns, [".git/**", "*.tmp"]); }); test("parseArgs - throws on missing required arguments", () => { - assert.throws(() => { - parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]); - }, /required option/); + assert.throws(() => { + parseArgs(["node", "cli.js", "-r", "https://sync.example.com"]); + }, /required option/); }); test("parseArgs - throws on missing remote uri", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-t", - "mytoken", - "-v", - "default" - ]); - }, /--remote-uri/); + assert.throws(() => { + parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-t", + "mytoken", + "-v", + "default" + ]); + }, /--remote-uri/); }); test("parseArgs - throws on missing token", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-v", - "default" - ]); - }, /--token/); + assert.throws(() => { + parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-v", + "default" + ]); + }, /--token/); }); test("parseArgs - throws on missing vault name", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken" - ]); - }, /--vault-name/); + assert.throws(() => { + parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken" + ]); + }, /--vault-name/); }); test("parseArgs - default log level is INFO", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default" + ]); - assert.equal(args.logLevel, LogLevel.INFO); -}); - -test("parseArgs - parse DEBUG log level", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "DEBUG" - ]); - - assert.equal(args.logLevel, LogLevel.DEBUG); + assert.equal(args.logLevel, LogLevel.INFO); }); test("parseArgs - parse ERROR log level", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "ERROR" - ]); + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "mytoken", + "-v", + "default", + "--log-level", + "ERROR" + ]); - assert.equal(args.logLevel, LogLevel.ERROR); + assert.equal(args.logLevel, LogLevel.ERROR); }); -test("parseArgs - log level is case insensitive", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "debug" - ]); - assert.equal(args.logLevel, LogLevel.DEBUG); +test("parseArgs - reads required options from environment variables", () => { + process.env.VAULTLINK_LOCAL_PATH = "/env/path"; + process.env.VAULTLINK_REMOTE_URI = "https://env.example.com"; + process.env.VAULTLINK_TOKEN = "env-token"; + process.env.VAULTLINK_VAULT_NAME = "env-vault"; + + try { + const args = parseArgs(["node", "cli.js"]); + assert.equal(args.localPath, "/env/path"); + assert.equal(args.remoteUri, "https://env.example.com"); + assert.equal(args.token, "env-token"); + assert.equal(args.vaultName, "env-vault"); + } finally { + delete process.env.VAULTLINK_LOCAL_PATH; + delete process.env.VAULTLINK_REMOTE_URI; + delete process.env.VAULTLINK_TOKEN; + delete process.env.VAULTLINK_VAULT_NAME; + } }); -test("parseArgs - throws on invalid log level", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "INVALID" - ]); - }, /Invalid log level/); +test("parseArgs - CLI arguments take precedence over environment variables", () => { + process.env.VAULTLINK_TOKEN = "env-token"; + + try { + const args = parseArgs([ + "node", + "cli.js", + "-l", + "/path/to/vault", + "-r", + "https://sync.example.com", + "-t", + "cli-token", + "-v", + "default" + ]); + assert.equal(args.token, "cli-token"); + } finally { + delete process.env.VAULTLINK_TOKEN; + } }); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 08ef2a6b..34d839b1 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -1,122 +1,229 @@ -import { Command } from "commander"; +import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; -export interface CliArgs { - remoteUri: string; - token: string; - vaultName: string; - localPath: string; - syncConcurrency?: number; - maxFileSizeMB?: number; - ignorePatterns?: string[]; - webSocketRetryIntervalMs?: number; - logLevel: LogLevel; +export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const; +export type LineEndingMode = (typeof LINE_ENDING_MODES)[number]; + +interface CliArgs { + remoteUri: string; + token: string; + vaultName: string; + localPath: string; + maxFileSizeMB?: number; + ignorePatterns?: string[]; + webSocketRetryIntervalMs?: number; + logLevel: LogLevel; + health?: string; + enableTelemetry?: boolean; + quiet: boolean; + lineEndings: LineEndingMode; +} + +const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; + +const REQUIRED_OPTIONS = { + localPath: { + flags: "-l, --local-path ", + env: "VAULTLINK_LOCAL_PATH" + }, + remoteUri: { + flags: "-r, --remote-uri ", + env: "VAULTLINK_REMOTE_URI" + }, + token: { flags: "-t, --token ", env: "VAULTLINK_TOKEN" }, + vaultName: { + flags: "-v, --vault-name ", + env: "VAULTLINK_VAULT_NAME" + } +} as const; + +function requireOption( + value: T | undefined, + name: keyof typeof REQUIRED_OPTIONS +): T { + if (value === undefined) { + const { flags, env } = REQUIRED_OPTIONS[name]; + throw new Error( + `required option '${flags}' not specified (or set ${env})` + ); + } + return value; } export function parseArgs(argv: string[]): CliArgs { - const program = new Command(); + const program = new Command(); - program - .name("vaultlink") - .description( - "VaultLink Local CLI - Sync your vault to the local filesystem" - ) - .version(packageJson.version) - .option("-l, --local-path ", "Local directory path to sync") - .option("-r, --remote-uri ", "Remote server URI") - .option("-t, --token ", "Authentication token") - .option("-v, --vault-name ", "Vault name") - .option( - "--sync-concurrency ", - "[OPTIONAL] Number of concurrent sync operations", - parseInt - ) - .option( - "--max-file-size-mb ", - "[OPTIONAL] Maximum file size in MB", - parseInt - ) - .option( - "--ignore-pattern ", - "[OPTIONAL] Patterns to ignore (can be specified multiple times)" - ) - .option( - "--websocket-retry-interval-ms ", - "[OPTIONAL] WebSocket retry interval in milliseconds", - parseInt - ) - .option( - "--log-level ", - "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)", - "INFO" - ) - .addHelpText( - "after", - ` + program + .name("vaultlink") + .description( + "VaultLink Local CLI - Sync your vault to the local filesystem" + ) + .version(packageJson.version) + .addOption( + new Option( + REQUIRED_OPTIONS.localPath.flags, + "Local directory path to sync" + ).env(REQUIRED_OPTIONS.localPath.env) + ) + .addOption( + new Option( + REQUIRED_OPTIONS.remoteUri.flags, + "Remote server URI" + ).env(REQUIRED_OPTIONS.remoteUri.env) + ) + .addOption( + new Option( + REQUIRED_OPTIONS.token.flags, + "Authentication token" + ).env(REQUIRED_OPTIONS.token.env) + ) + .addOption( + new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env( + REQUIRED_OPTIONS.vaultName.env + ) + ) + .addOption( + new Option( + "--max-file-size-mb ", + "[OPTIONAL] Maximum file size in MB" + ) + .argParser(parseInt) + .env("VAULTLINK_MAX_FILE_SIZE_MB") + ) + .addOption( + new Option( + "--ignore-pattern ", + "[OPTIONAL] Patterns to ignore (can be specified multiple times)" + ).env("VAULTLINK_IGNORE_PATTERNS") + ) + .addOption( + new Option( + "--websocket-retry-interval-ms ", + "[OPTIONAL] WebSocket retry interval in milliseconds" + ) + .argParser(parseInt) + .env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS") + ) + .addOption( + new Option( + "--log-level ", + "[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)" + ) + .default("INFO") + .env("VAULTLINK_LOG_LEVEL") + ) + .addOption( + new Option( + "--health ", + "[OPTIONAL] Path to health status file for Docker healthcheck" + ).env("VAULTLINK_HEALTH") + ) + .addOption( + new Option( + "--enable-telemetry", + "[OPTIONAL] Enable telemetry (disabled by default)" + ).env("VAULTLINK_ENABLE_TELEMETRY") + ) + .addOption( + new Option( + "-q, --quiet", + "[OPTIONAL] Suppress startup banner for non-interactive use" + ).env("VAULTLINK_QUIET") + ) + .addOption( + new Option( + "--line-endings ", + "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" + ) + .default("auto") + .choices([...LINE_ENDING_MODES]) + .env("VAULTLINK_LINE_ENDINGS") + ) + .addHelpText( + "after", + ` Examples: $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --ignore-pattern ".git/**" --ignore-pattern "*.tmp" + --ignore-pattern ".git/**" --ignore-pattern "**/*.tmp" $ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\ - --log-level DEBUG + --log-level DEBUG --quiet + +Environment variables: + All options can be configured via VAULTLINK_ prefixed environment variables. + CLI arguments take precedence over environment variables. ` - ); + ); - program.parse(argv); + program.parse(argv); - /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ - const opts = program.opts(); - const localPath = opts.localPath as string | undefined; - const remoteUri = opts.remoteUri as string | undefined; - const token = opts.token as string | undefined; - const vaultName = opts.vaultName as string | undefined; - const syncConcurrency = opts.syncConcurrency as number | undefined; - const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; - const ignorePattern = opts.ignorePattern as string[] | undefined; - const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as - | number - | undefined; - const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; - /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const opts = program.opts(); + const localPath = opts.localPath as string | undefined; + const remoteUri = opts.remoteUri as string | undefined; + const token = opts.token as string | undefined; + const vaultName = opts.vaultName as string | undefined; + const maxFileSizeMb = opts.maxFileSizeMb as number | undefined; + const ignorePattern = opts.ignorePattern as string[] | undefined; + const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as + | number + | undefined; + const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO"; + const health = opts.health as string | undefined; + const enableTelemetry = opts.enableTelemetry as boolean | undefined; + const quiet = (opts.quiet as boolean | undefined) ?? false; + const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - if (localPath === undefined) { - throw new Error( - "required option '-l, --local-path ' not specified" - ); - } - if (remoteUri === undefined) { - throw new Error("required option '--remote-uri ' not specified"); - } - if (token === undefined) { - throw new Error("required option '--token ' not specified"); - } - if (vaultName === undefined) { - throw new Error("required option '--vault-name ' not specified"); - } + const requiredLocalPath = requireOption(localPath, "localPath"); + const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); + const requiredToken = requireOption(token, "token"); + const requiredVaultName = requireOption(vaultName, "vaultName"); - // Validate and parse log level - const logLevelUpper = logLevelStr.toUpperCase(); - const validLogLevels = Object.values(LogLevel); - const isLogLevel = (value: string): value is LogLevel => { - return (validLogLevels as readonly string[]).includes(value); - }; - if (!isLogLevel(logLevelUpper)) { - throw new Error( - `Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}` - ); - } - const logLevel = logLevelUpper; + // Validate remote URI protocol + if ( + !VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix)) + ) { + throw new Error( + `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}` + ); + } - return { - localPath, - remoteUri, - token, - vaultName, - syncConcurrency, - maxFileSizeMB: maxFileSizeMb, - ignorePatterns: ignorePattern, - webSocketRetryIntervalMs: websocketRetryIntervalMs, - logLevel - }; + // Validate and parse log level + const logLevelUpper = logLevelStr.toUpperCase(); + const validLogLevels = Object.values(LogLevel); + const isLogLevel = (value: string): value is LogLevel => { + return (validLogLevels as readonly string[]).includes(value); + }; + if (!isLogLevel(logLevelUpper)) { + throw new Error( + `Invalid log level '${logLevelStr}'. Valid values are: ${validLogLevels.join(", ")}` + ); + } + const logLevel = logLevelUpper; + + const isLineEndingMode = (value: string): value is LineEndingMode => + (LINE_ENDING_MODES as readonly string[]).includes(value); + if (!isLineEndingMode(lineEndingsStr)) { + throw new Error( + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}` + ); + } + const lineEndings = lineEndingsStr; + + return { + localPath: requiredLocalPath, + remoteUri: requiredRemoteUri, + token: requiredToken, + vaultName: requiredVaultName, + maxFileSizeMB: maxFileSizeMb, + ignorePatterns: ignorePattern, + webSocketRetryIntervalMs: websocketRetryIntervalMs, + logLevel, + health, + enableTelemetry, + quiet, + lineEndings + }; } diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 5a3c6546..31c81d5c 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -1,207 +1,287 @@ import * as path from "path"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; import { - SyncClient, - DEFAULT_SETTINGS, - LogLevel, - type SyncSettings, - type StoredDatabase + SyncClient, + DEFAULT_SETTINGS, + Logger, + LogLevel, + LogLine, + type SyncSettings, + type StoredDatabase } from "sync-client"; -import { parseArgs } from "./args"; -import { NodeFileSystemOperations } from "./node-filesystem"; +import { parseArgs, type LineEndingMode } from "./args"; +import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; -import { formatLogLine, colorize, styleText } from "./logger-formatter"; +import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; +function writeHealthStatus( + logger: Logger, + filePath: string, + connectionStatus: NetworkConnectionStatus +): void { + try { + fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus)); + } catch (error) { + logger.error( + `Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + const LOG_LEVEL_ORDER = { - [LogLevel.DEBUG]: 0, - [LogLevel.INFO]: 1, - [LogLevel.WARNING]: 2, - [LogLevel.ERROR]: 3 + [LogLevel.DEBUG]: 0, + [LogLevel.INFO]: 1, + [LogLevel.WARNING]: 2, + [LogLevel.ERROR]: 3 }; +function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { + return (logLine: LogLine): void => { + if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) { + // eslint-disable-next-line no-console + console.log(formatLogLine(logLine)); + } + }; +} + +const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; +const PROGRESS_LOG_INTERVAL_MS = 2000; + +function resolveLineEndings(mode: LineEndingMode): string { + switch (mode) { + case "lf": + return "\n"; + case "crlf": + return "\r\n"; + case "auto": + return process.platform === "win32" ? "\r\n" : "\n"; + } +} + async function main(): Promise { - const args = parseArgs(process.argv); - const absolutePath = path.resolve(args.localPath); + const args = parseArgs(process.argv); + const absolutePath = path.resolve(args.localPath); - try { - const stats = await fs.stat(absolutePath); - if (!stats.isDirectory()) { - console.error( - colorize(`Error: ${absolutePath} is not a directory`, "red") - ); - process.exit(1); - } - } catch (error) { - console.error( - colorize( - `Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) - ); - process.exit(1); - } + const logHandler = createLogHandler(args.logLevel); + // Boot-time messages are emitted directly through logHandler before the + // SyncClient (and its Logger) exist; afterwards every log line flows + // through client.logger. + const emitBoot = (level: LogLevel, message: string): void => { + logHandler(new LogLine(level, message)); + }; - console.log( - styleText("VaultLink Local CLI", "bold", "cyan") + - colorize(` v${packageJson.version}`, "dim") - ); - console.log(colorize("=".repeat(50), "dim")); - console.log( - `${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}` - ); - console.log( - `${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}` - ); - console.log( - `${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}` - ); - console.log(""); + if (!fsSync.existsSync(absolutePath)) { + fsSync.mkdirSync(absolutePath, { recursive: true }); + } - const dataDir = path.join(absolutePath, ".vaultlink"); - const dataFile = path.join(dataDir, "sync-data.json"); + try { + const stats = await fs.stat(absolutePath); + if (!stats.isDirectory()) { + emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`); + process.exit(1); + } + } catch (error) { + emitBoot( + LogLevel.ERROR, + `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } - await fs.mkdir(dataDir, { recursive: true }); + if (!args.quiet) { + emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`); + emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`); + emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`); + emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`); + if (args.lineEndings !== "auto") { + emitBoot( + LogLevel.INFO, + `Line endings: ${args.lineEndings.toUpperCase()}` + ); + } + } - const fileSystem = new NodeFileSystemOperations(absolutePath); + const dataDir = path.join(absolutePath, VAULTLINK_DIR); + const dataFile = path.join(dataDir, "sync-data.json"); - const ignorePatterns = [ - ...(args.ignorePatterns ?? []), - ".vaultlink/**", - ".git/**" - ]; + await fs.mkdir(dataDir, { recursive: true }); - const settings: SyncSettings = { - remoteUri: args.remoteUri, - token: args.token, - vaultName: args.vaultName, - syncConcurrency: - args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency, - maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, - ignorePatterns, - webSocketRetryIntervalMs: - args.webSocketRetryIntervalMs ?? - DEFAULT_SETTINGS.webSocketRetryIntervalMs, - isSyncEnabled: true - }; + const fileSystem = new NodeFileSystemOperations(absolutePath); - const client = await SyncClient.create({ - fs: fileSystem, - persistence: { - load: async () => { - let database: Partial | undefined = undefined; - try { - const content = await fs.readFile(dataFile, "utf-8"); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - database = JSON.parse(content) as Partial; - } catch { - console.error( - colorize( - `Cannot read data file at ${dataFile}`, - "yellow" - ) - ); - } + const ignorePatterns = [ + ...(args.ignorePatterns ?? []), + `${VAULTLINK_DIR}/**` + ]; - return { - settings, - database - }; - }, - save: async ({ database: persistedDatabase }) => { - // settings can't be updated when running with this CLI - await fs.writeFile( - dataFile, - JSON.stringify(persistedDatabase, null, 2) - ); - } - }, - nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n" - }); + const settings: SyncSettings = { + ...DEFAULT_SETTINGS, + remoteUri: args.remoteUri, + token: args.token, + vaultName: args.vaultName, + maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB, + ignorePatterns, + webSocketRetryIntervalMs: + args.webSocketRetryIntervalMs ?? + DEFAULT_SETTINGS.webSocketRetryIntervalMs, + isSyncEnabled: true, + enableTelemetry: + args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry + }; - // Add colored log formatter with level filtering - client.logger.addOnMessageListener((logLine) => { - // Only show messages at or above the configured log level - if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) { - console.log(formatLogLine(logLine)); - } - }); + const client = await SyncClient.create({ + fs: fileSystem, + persistence: { + load: async () => { + let database: Partial | undefined = undefined; + try { + const content = await fs.readFile(dataFile, "utf-8"); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + database = JSON.parse(content) as Partial; + } catch { + emitBoot( + LogLevel.WARNING, + `Cannot read data file at ${dataFile}` + ); + } - client.logger.info("Starting sync client"); + return { + settings, + database + }; + }, + save: async ({ database: persistedDatabase }) => { + await fs.writeFile( + dataFile, + JSON.stringify(persistedDatabase, null, 2) + ); + } + }, + nativeLineEndings: resolveLineEndings(args.lineEndings) + }); - const fileWatcher = new FileWatcher(absolutePath, client); + if (args.health !== undefined) { + const healthFile = args.health; + const writeHealth = (): void => { + void client.checkConnection().then((status) => { + writeHealthStatus(client.logger, healthFile, status); + }); + }; + writeHealth(); + const healthInterval = setInterval( + writeHealth, + HEALTH_CHECK_INTERVAL_MS + ); + const clearHealthInterval = (): void => { + clearInterval(healthInterval); + }; + process.on("SIGINT", clearHealthInterval); + process.on("SIGTERM", clearHealthInterval); + process.on("exit", clearHealthInterval); + } - client.addWebSocketStatusChangeListener(() => { - client.logger.info("WebSocket status changed"); - }); + client.logger.onLogEmitted.add(logHandler); + client.logger.info("Starting sync client"); - client.addRemainingSyncOperationsListener((remaining) => { - if (remaining === 0) { - client.logger.info("All sync operations completed"); - } else { - client.logger.info(`${remaining} sync operations remaining`); - } - }); + const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns); - const gracefulShutdown = async (signal: string): Promise => { - console.log( - colorize( - `\n${signal} received. Shutting down gracefully...`, - "yellow" - ) - ); + client.onWebSocketStatusChanged.add(() => { + const isConnected = client.isWebSocketConnected; + client.logger.info( + `WebSocket status changed: ${isConnected ? "connected" : "disconnected"}` + ); + }); - fileWatcher.stop(); - await client.waitAndStop(); - console.log(colorize("Shutdown complete", "green")); - process.exit(0); - }; + let syncBatchSize = 0; + let totalSyncOps = 0; + let lastProgressLogTime = 0; - process.on("SIGINT", () => { - void gracefulShutdown("SIGINT"); - }); - process.on("SIGTERM", () => { - void gracefulShutdown("SIGTERM"); - }); + client.onRemainingOperationsCountChanged.add((remaining) => { + if (remaining > syncBatchSize) { + syncBatchSize = remaining; + } - try { - const connectionStatus = await client.checkConnection(); - if (!connectionStatus.isSuccessful) { - console.error( - colorize( - `Error: Cannot connect to server: ${connectionStatus.serverMessage}`, - "red" - ) - ); - process.exit(1); - } + if (remaining === 0) { + if (syncBatchSize > 0) { + totalSyncOps += syncBatchSize; + client.logger.info( + `Sync batch complete (${syncBatchSize} operations)` + ); + syncBatchSize = 0; + } + } else { + const now = Date.now(); + if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) { + client.logger.info( + `Syncing: ${remaining} operations remaining` + ); + lastProgressLogTime = now; + } + } + }); - console.log(`${colorize("✓", "green")} Server connection successful`); - console.log(colorize("Press Ctrl+C to stop", "dim")); - console.log(""); + let isShuttingDown = false; + const gracefulShutdown = async (signal: string): Promise => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; - await client.start(); - fileWatcher.start(); - } catch (error) { - console.error( - colorize( - `Fatal error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) - ); + client.logger.info(`${signal} received, shutting down gracefully`); - fileWatcher.stop(); - await client.waitAndStop(); - process.exit(1); - } + fileWatcher.stop(); + await client.waitUntilFinished(); + await client.destroy(); + + if (totalSyncOps > 0) { + client.logger.info( + `Shutdown complete (${totalSyncOps} operations synced)` + ); + } else { + client.logger.info("Shutdown complete"); + } + process.exit(0); + }; + + process.on("SIGINT", () => { + void gracefulShutdown("SIGINT"); + }); + process.on("SIGTERM", () => { + void gracefulShutdown("SIGTERM"); + }); + + try { + const connectionStatus = await client.checkConnection(); + if (!connectionStatus.isSuccessful) { + client.logger.error( + `Cannot connect to server: ${connectionStatus.serverMessage}` + ); + process.exit(1); + } + + if (!args.quiet) { + client.logger.info("Server connection successful"); + } + + await client.start(); + fileWatcher.start(); + } catch (error) { + client.logger.error( + `Fatal error: ${error instanceof Error ? error.message : String(error)}` + ); + + fileWatcher.stop(); + await client.destroy(); + process.exit(1); + } } main().catch((error: unknown) => { - console.error( - colorize( - `Unexpected error: ${error instanceof Error ? error.message : String(error)}`, - "red" - ) - ); - process.exit(1); + // eslint-disable-next-line no-console + console.error( + `Unexpected error: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); }); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index 65577bc4..c273a412 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -1,102 +1,93 @@ -import * as fs from "fs"; +import Watcher from "watcher"; import * as path from "path"; import type { SyncClient, RelativePath } from "sync-client"; +import { toUnixPath, matchesGlob } from "./path-utils"; export class FileWatcher { - private watcher: fs.FSWatcher | undefined; - private isRunning = false; + private watcher: Watcher | undefined; + private isRunning = false; + private readonly ignorePatterns: string[]; - public constructor( - private readonly basePath: string, - private readonly client: SyncClient - ) {} + public constructor( + private readonly basePath: string, + private readonly client: SyncClient, + ignorePatterns: string[] = [] + ) { + this.ignorePatterns = ignorePatterns; + } - public start(): void { - if (this.isRunning) { - return; - } + public start(): void { + if (this.isRunning) { + return; + } - this.isRunning = true; + this.isRunning = true; - this.watcher = fs.watch( - this.basePath, - { recursive: true }, - (eventType, filename) => { - if (filename === null || filename.length === 0) { - return; - } + this.watcher = new Watcher(this.basePath, { + recursive: true, + renameDetection: true, + renameTimeout: 125, + ignoreInitial: true, + ignore: (filePath: string): boolean => this.shouldIgnore(filePath) + }); - // Convert to forward slashes for consistency - const relativePath = this.toUnixPath(filename); + this.watcher.on("add", (filePath: string) => { + this.handleCreate(this.toRelativePath(filePath)); + }); - if (eventType === "rename") { - this.handleRenameOrDelete(relativePath); - } else { - // Must be "change" event - this.handleChange(relativePath); - } - } - ); + this.watcher.on("change", (filePath: string) => { + this.handleChange(this.toRelativePath(filePath)); + }); - this.client.logger.info("File watcher started"); - } + this.watcher.on("unlink", (filePath: string) => { + this.handleDelete(this.toRelativePath(filePath)); + }); - public stop(): void { - if (this.watcher !== undefined) { - this.watcher.close(); - this.watcher = undefined; - } - this.isRunning = false; - this.client.logger.info("File watcher stopped"); - } + this.watcher.on("rename", (oldPath: string, newPath: string) => { + this.handleRename( + this.toRelativePath(oldPath), + this.toRelativePath(newPath) + ); + }); - private handleChange(relativePath: RelativePath): void { - this.client - .syncLocallyUpdatedFile({ relativePath }) - .catch((err: unknown) => { - this.client.logger.error( - `Failed to sync updated file ${relativePath}: ${err instanceof Error ? err.message : String(err)}` - ); - }); - } + this.client.logger.info("File watcher started"); + } - private handleRenameOrDelete(relativePath: RelativePath): void { - const fullPath = path.join(this.basePath, relativePath); + public stop(): void { + if (this.watcher !== undefined) { + this.watcher.close(); + this.watcher = undefined; + } + this.isRunning = false; + this.client.logger.info("File watcher stopped"); + } - fs.access(fullPath, fs.constants.F_OK, (accessError) => { - if (accessError) { - this.client - .syncLocallyDeletedFile(relativePath) - .catch((deleteErr: unknown) => { - this.client.logger.error( - `Failed to sync deleted file ${relativePath}: ${deleteErr instanceof Error ? deleteErr.message : String(deleteErr)}` - ); - }); - } else { - fs.stat(fullPath, (statErr, stats) => { - if (statErr !== null || !stats.isFile()) { - return; - } + private shouldIgnore(filePath: string): boolean { + const rel = toUnixPath(path.relative(this.basePath, filePath)); + return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern)); + } - this.client - .syncLocallyCreatedFile(relativePath) - .catch((createErr: unknown) => { - this.client.logger.error( - `Failed to sync created file ${relativePath}: ${createErr instanceof Error ? createErr.message : String(createErr)}` - ); - }); - }); - } - }); - } + private handleCreate(relativePath: RelativePath): void { + this.client.syncLocallyCreatedFile(relativePath); + } - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } + private handleChange(relativePath: RelativePath): void { + this.client.syncLocallyUpdatedFile({ relativePath }); + } + + private handleDelete(relativePath: RelativePath): void { + this.client.syncLocallyDeletedFile(relativePath); + } + + private handleRename(oldPath: RelativePath, newPath: RelativePath): void { + this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`); + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); + } + + private toRelativePath(absolutePath: string): RelativePath { + return toUnixPath(path.relative(this.basePath, absolutePath)); + } } diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts new file mode 100644 index 00000000..d7211c88 --- /dev/null +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +/** + * Healthcheck script for Docker container + * Checks if the sync client is connected to the server + */ + +import * as fs from "fs"; +import type { NetworkConnectionStatus } from "sync-client"; + +function isHealthStatus(value: unknown): value is NetworkConnectionStatus { + if (typeof value !== "object" || value === null) { + return false; + } + + return ( + "isSuccessful" in value && + typeof value.isSuccessful === "boolean" && + "isWebSocketConnected" in value && + typeof value.isWebSocketConnected === "boolean" && + "serverMessage" in value && + typeof value.serverMessage === "string" + ); +} + +function main(): void { + if (process.argv.length < 3) { + console.error("Usage: healthcheck "); + process.exit(1); + } + const [, , healthFile] = process.argv; + + try { + // Check if health file exists + if (!fs.existsSync(healthFile)) { + console.error(`Health file does not exist: ${healthFile}`); + process.exit(1); + } + + // Read and parse health status + const content = fs.readFileSync(healthFile, "utf-8"); + const parsed: unknown = JSON.parse(content); + + // Validate the parsed object using type guard + if (!isHealthStatus(parsed)) { + throw new Error("Invalid health status format"); + } + + const status = parsed; + + if (!status.isSuccessful || !status.isWebSocketConnected) { + console.error("Not connected to server: " + status.serverMessage); + process.exit(1); + } + + console.log("Healthy: Connected to server"); + process.exit(0); + } catch (error) { + console.error( + `Health check failed: ${error instanceof Error ? error.message : String(error)}` + ); + process.exit(1); + } +} + +main(); diff --git a/frontend/local-client-cli/src/logger-formatter.test.ts b/frontend/local-client-cli/src/logger-formatter.test.ts new file mode 100644 index 00000000..f3078242 --- /dev/null +++ b/frontend/local-client-cli/src/logger-formatter.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { formatLogLine } from "./logger-formatter"; +import { LogLevel } from "sync-client"; + +test("formatLogLine - includes level and message", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Test message" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("INFO")); + assert.ok(result.includes("Test message")); +}); + +test("formatLogLine - ERROR level messages contain bold escape", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.ERROR, + message: "Error occurred" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[1m")); +}); + +test("formatLogLine - highlights file paths in quotes", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: 'Syncing "notes/test.md"' + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[35m")); +}); + +test("formatLogLine - highlights standalone numbers but not numbers in versions", () => { + const logLine = { + timestamp: new Date("2024-01-15T10:30:45.123Z"), + level: LogLevel.INFO, + message: "Listed 42 files from v1.2.3" + }; + + const result = formatLogLine(logLine); + assert.ok(result.includes("\x1b[36m42\x1b[0m")); + assert.ok(!result.includes("\x1b[36m1\x1b[0m.")); +}); diff --git a/frontend/local-client-cli/src/logger-formatter.ts b/frontend/local-client-cli/src/logger-formatter.ts index 994adc74..b98415b6 100644 --- a/frontend/local-client-cli/src/logger-formatter.ts +++ b/frontend/local-client-cli/src/logger-formatter.ts @@ -1,86 +1,71 @@ import { LogLevel, type LogLine } from "sync-client"; -// ANSI color codes -export const colors = { - reset: "\x1b[0m", - bold: "\x1b[1m", - dim: "\x1b[2m", +const colors = { + reset: "\x1b[0m", + bold: "\x1b[1m", - // Foreground colors - red: "\x1b[31m", - green: "\x1b[32m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - magenta: "\x1b[35m", - cyan: "\x1b[36m", - gray: "\x1b[90m" + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", + gray: "\x1b[90m" } as const; -export function colorize(text: string, color: keyof typeof colors): string { - return `${colors[color]}${text}${colors.reset}`; -} - -/** - * Helper function to apply multiple color modifiers to text - */ -export function styleText( - text: string, - ...modifiers: (keyof typeof colors)[] -): string { - const prefix = modifiers.map((m) => colors[m]).join(""); - return `${prefix}${text}${colors.reset}`; +function colorize(text: string, color: keyof typeof colors): string { + return `${colors[color]}${text}${colors.reset}`; } function formatTimestamp(date: Date): string { - const [time] = date.toTimeString().split(" "); - const ms = date.getMilliseconds().toString().padStart(3, "0"); - return colorize(`${time}.${ms}`, "gray"); + const [time] = date.toTimeString().split(" "); + const ms = date.getMilliseconds().toString().padStart(3, "0"); + return colorize(`${time}.${ms}`, "gray"); } function formatLevel(level: LogLevel): string { - const levelStr = level.padEnd(7); - switch (level) { - case LogLevel.DEBUG: - return colorize(levelStr, "cyan"); - case LogLevel.INFO: - return colorize(levelStr, "green"); - case LogLevel.WARNING: - return colorize(levelStr, "yellow"); - case LogLevel.ERROR: - return colorize(levelStr, "red"); - } + const levelStr = level.padEnd(7); + switch (level) { + case LogLevel.DEBUG: + return colorize(levelStr, "cyan"); + case LogLevel.INFO: + return colorize(levelStr, "green"); + case LogLevel.WARNING: + return colorize(levelStr, "yellow"); + case LogLevel.ERROR: + return colorize(levelStr, "red"); + } } function formatMessage(message: string, level: LogLevel): string { - // Highlight important parts of the message - let formatted = message; + // Highlight important parts of the message + let formatted = message; - // Highlight file paths - formatted = formatted.replace( - /(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g, - (_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2 - ); + // Highlight file paths + formatted = formatted.replace( + /(['"])([^'"]*?\.(json|txt|md|js|ts))(['"])/g, + (_, q1, path, _ext, q2) => q1 + colorize(path, "magenta") + q2 + ); - // Highlight numbers - formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan")); + // Highlight numbers + formatted = formatted.replace(/\b(\d+)\b/g, (num) => colorize(num, "cyan")); - // Highlight patterns like /regex/ - formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) => - colorize(pattern, "yellow") - ); + // Highlight patterns like /regex/ + formatted = formatted.replace(/(\/\^[^$]*\$\/)/g, (pattern) => + colorize(pattern, "yellow") + ); - // Make error messages bold - if (level === LogLevel.ERROR) { - formatted = colorize(formatted, "bold"); - } + // Make error messages bold + if (level === LogLevel.ERROR) { + formatted = colorize(formatted, "bold"); + } - return formatted; + return formatted; } export function formatLogLine(logLine: LogLine): string { - const timestamp = formatTimestamp(logLine.timestamp); - const level = formatLevel(logLine.level); - const message = formatMessage(logLine.message, logLine.level); + const timestamp = formatTimestamp(logLine.timestamp); + const level = formatLevel(logLine.level); + const message = formatMessage(logLine.message, logLine.level); - return `${timestamp} ${level} ${message}`; + return `${timestamp} ${level} ${message}`; } diff --git a/frontend/local-client-cli/src/node-filesystem.test.ts b/frontend/local-client-cli/src/node-filesystem.test.ts index 4a72da94..85fb7a0f 100644 --- a/frontend/local-client-cli/src/node-filesystem.test.ts +++ b/frontend/local-client-cli/src/node-filesystem.test.ts @@ -6,157 +6,157 @@ import * as os from "os"; import { NodeFileSystemOperations } from "./node-filesystem"; test("NodeFileSystemOperations - read and write files", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - const content = new TextEncoder().encode("Hello, world!"); - await fsOps.write("test.txt", content); + try { + const content = new TextEncoder().encode("Hello, world!"); + await fsOps.write("test.txt", content); - const readContent = await fsOps.read("test.txt"); - assert.equal(new TextDecoder().decode(readContent), "Hello, world!"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const readContent = await fsOps.read("test.txt"); + assert.equal(new TextDecoder().decode(readContent), "Hello, world!"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - create nested directories with forward slashes", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - const content = new TextEncoder().encode("Nested file"); - // Always use forward slashes in API - await fsOps.write("dir1/dir2/test.txt", content); + try { + const content = new TextEncoder().encode("Nested file"); + // Always use forward slashes in API + await fsOps.write("dir1/dir2/test.txt", content); - const readContent = await fsOps.read("dir1/dir2/test.txt"); - assert.equal(new TextDecoder().decode(readContent), "Nested file"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const readContent = await fsOps.read("dir1/dir2/test.txt"); + assert.equal(new TextDecoder().decode(readContent), "Nested file"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - exists with forward slashes", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - assert.equal(await fsOps.exists("test.txt"), false); + try { + assert.equal(await fsOps.exists("test.txt"), false); - await fsOps.write("test.txt", new TextEncoder().encode("test")); + await fsOps.write("test.txt", new TextEncoder().encode("test")); - assert.equal(await fsOps.exists("test.txt"), true); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + assert.equal(await fsOps.exists("test.txt"), true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - delete with forward slashes", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - await fsOps.write("test.txt", new TextEncoder().encode("test")); - assert.equal(await fsOps.exists("test.txt"), true); + try { + await fsOps.write("test.txt", new TextEncoder().encode("test")); + assert.equal(await fsOps.exists("test.txt"), true); - await fsOps.delete("test.txt"); - assert.equal(await fsOps.exists("test.txt"), false); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + await fsOps.delete("test.txt"); + assert.equal(await fsOps.exists("test.txt"), false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - rename with forward slashes", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - const content = new TextEncoder().encode("test content"); - await fsOps.write("old.txt", content); + try { + const content = new TextEncoder().encode("test content"); + await fsOps.write("old.txt", content); - await fsOps.rename("old.txt", "new.txt"); + await fsOps.rename("old.txt", "new.txt"); - assert.equal(await fsOps.exists("old.txt"), false); - assert.equal(await fsOps.exists("new.txt"), true); + assert.equal(await fsOps.exists("old.txt"), false); + assert.equal(await fsOps.exists("new.txt"), true); - const readContent = await fsOps.read("new.txt"); - assert.equal(new TextDecoder().decode(readContent), "test content"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const readContent = await fsOps.read("new.txt"); + assert.equal(new TextDecoder().decode(readContent), "test content"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - rename to nested path with forward slashes", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - const content = new TextEncoder().encode("test content"); - await fsOps.write("old.txt", content); + try { + const content = new TextEncoder().encode("test content"); + await fsOps.write("old.txt", content); - await fsOps.rename("old.txt", "dir1/dir2/new.txt"); + await fsOps.rename("old.txt", "dir1/dir2/new.txt"); - assert.equal(await fsOps.exists("old.txt"), false); - assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + assert.equal(await fsOps.exists("old.txt"), false); + assert.equal(await fsOps.exists("dir1/dir2/new.txt"), true); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - getFileSize", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - const content = new TextEncoder().encode("Hello!"); - await fsOps.write("test.txt", content); + try { + const content = new TextEncoder().encode("Hello!"); + await fsOps.write("test.txt", content); - const size = await fsOps.getFileSize("test.txt"); - assert.equal(size, content.length); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const size = await fsOps.getFileSize("test.txt"); + assert.equal(size, content.length); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - atomicUpdateText", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - await fsOps.write("test.txt", new TextEncoder().encode("Hello")); + try { + await fsOps.write("test.txt", new TextEncoder().encode("Hello")); - const result = await fsOps.atomicUpdateText("test.txt", (current) => ({ - text: current.text + " World", - cursors: [] - })); + const result = await fsOps.atomicUpdateText("test.txt", (current) => ({ + text: current.text + " World", + cursors: [] + })); - assert.equal(result, "Hello World"); + assert.equal(result, "Hello World"); - const content = await fsOps.read("test.txt"); - assert.equal(new TextDecoder().decode(content), "Hello World"); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + const content = await fsOps.read("test.txt"); + assert.equal(new TextDecoder().decode(content), "Hello World"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); - const fsOps = new NodeFileSystemOperations(tempDir); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-")); + const fsOps = new NodeFileSystemOperations(tempDir); - try { - // API should always accept forward slashes - const testPath = "deep/nested/directory/file.txt"; - const content = new TextEncoder().encode("test"); + try { + // API should always accept forward slashes + const testPath = "deep/nested/directory/file.txt"; + const content = new TextEncoder().encode("test"); - await fsOps.write(testPath, content); - assert.equal(await fsOps.exists(testPath), true); + await fsOps.write(testPath, content); + assert.equal(await fsOps.exists(testPath), true); - const readContent = await fsOps.read(testPath); - assert.equal(new TextDecoder().decode(readContent), "test"); + const readContent = await fsOps.read(testPath); + assert.equal(new TextDecoder().decode(readContent), "test"); - await fsOps.delete(testPath); - assert.equal(await fsOps.exists(testPath), false); - } finally { - await fs.rm(tempDir, { recursive: true, force: true }); - } + await fsOps.delete(testPath); + assert.equal(await fsOps.exists(testPath), false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } }); diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 252385c9..ba95ab6a 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -1,203 +1,201 @@ import * as fs from "fs/promises"; import type { Dirent } from "fs"; import * as path from "path"; -import type { FileSystemOperations, RelativePath } from "sync-client"; -import type { TextWithCursors } from "reconcile-text"; +import { randomUUID } from "crypto"; +import type { + FileSystemOperations, + RelativePath, + TextWithCursors +} from "sync-client"; +import { toUnixPath } from "./path-utils"; + +// VaultLink's per-vault metadata directory. Holds the persisted sync database +// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**` +// ignore pattern keeps everything in here invisible to the file watcher. +export const VAULTLINK_DIR = ".vaultlink"; export class NodeFileSystemOperations implements FileSystemOperations { - public constructor(private readonly basePath: string) {} + public constructor(private readonly basePath: string) { } - public async listFilesRecursively( - directory: RelativePath | undefined - ): Promise { - const files: RelativePath[] = []; - await this.walkDirectory( - directory !== undefined ? this.toNativePath(directory) : "", - files - ); - return files; - } + public async listFilesRecursively( + directory: RelativePath | undefined + ): Promise { + const files: RelativePath[] = []; + await this.walkDirectory(directory ?? "", files); + return files; + } - public async read(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - try { - return await fs.readFile(fullPath); - } catch (error) { - throw new Error( - `Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + public async read(relativePath: RelativePath): Promise { + const fullPath = path.join(this.basePath, relativePath); + try { + return await fs.readFile(fullPath); + } catch (error) { + throw new Error( + `Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async write( - relativePath: RelativePath, - content: Uint8Array - ): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - const dir = path.dirname(fullPath); + public async write( + relativePath: RelativePath, + content: Uint8Array + ): Promise { + const fullPath = path.join(this.basePath, relativePath); + const dir = path.dirname(fullPath); - try { - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(fullPath, content); - } catch (error) { - throw new Error( - `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + try { + await fs.mkdir(dir, { recursive: true }); + await this.atomicWrite(fullPath, content); + } catch (error) { + throw new Error( + `Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async atomicUpdateText( - relativePath: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); + public async atomicUpdateText( + relativePath: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + const fullPath = path.join(this.basePath, relativePath); - try { - const currentContent = await fs.readFile(fullPath, "utf-8"); - const result = updater({ text: currentContent, cursors: [] }); - await fs.writeFile(fullPath, result.text, "utf-8"); - return result.text; - } catch (error) { - throw new Error( - `Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + try { + const currentContent = await fs.readFile(fullPath, "utf-8"); + const result = updater({ text: currentContent, cursors: [] }); + await this.atomicWrite(fullPath, result.text, "utf-8"); + return result.text; + } catch (error) { + throw new Error( + `Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async getFileSize(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - try { - const stats = await fs.stat(fullPath); - return stats.size; - } catch (error) { - throw new Error( - `Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + public async getFileSize(relativePath: RelativePath): Promise { + const fullPath = path.join(this.basePath, relativePath); + try { + const stats = await fs.stat(fullPath); + return stats.size; + } catch (error) { + throw new Error( + `Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async exists(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - try { - await fs.access(fullPath); - return true; - } catch { - return false; - } - } + public async exists(relativePath: RelativePath): Promise { + const fullPath = path.join(this.basePath, relativePath); + try { + await fs.access(fullPath); + return true; + } catch { + return false; + } + } - public async createDirectory(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - try { - await fs.mkdir(fullPath, { recursive: false }); - } catch (error) { - throw new Error( - `Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + public async createDirectory(relativePath: RelativePath): Promise { + const fullPath = path.join(this.basePath, relativePath); + try { + await fs.mkdir(fullPath, { recursive: false }); + } catch (error) { + throw new Error( + `Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async delete(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - this.toNativePath(relativePath) - ); - try { - await fs.unlink(fullPath); - } catch (error) { - throw new Error( - `Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + public async delete(relativePath: RelativePath): Promise { + const fullPath = path.join(this.basePath, relativePath); + try { + await fs.unlink(fullPath); + } catch (error) { + throw new Error( + `Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - const oldFullPath = path.join( - this.basePath, - this.toNativePath(oldPath) - ); - const newFullPath = path.join( - this.basePath, - this.toNativePath(newPath) - ); - const newDir = path.dirname(newFullPath); + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + const oldFullPath = path.join(this.basePath, oldPath); + const newFullPath = path.join(this.basePath, newPath); + const newDir = path.dirname(newFullPath); - try { - await fs.mkdir(newDir, { recursive: true }); - await fs.rename(oldFullPath, newFullPath); - } catch (error) { - throw new Error( - `Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } - } + try { + await fs.mkdir(newDir, { recursive: true }); + await fs.rename(oldFullPath, newFullPath); + } catch (error) { + throw new Error( + `Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } - private async walkDirectory( - relativePath: string, - files: RelativePath[] - ): Promise { - const fullPath = path.join(this.basePath, relativePath); - let entries: Dirent[] = []; + private async atomicWrite( + fullPath: string, + content: Uint8Array | string, + encoding?: BufferEncoding + ): Promise { + const tmpDir = path.join(this.basePath, VAULTLINK_DIR); + await fs.mkdir(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`); + try { + await fs.writeFile(tmpPath, content, encoding); + const fd = await fs.open(tmpPath, "r"); + try { + await fd.datasync(); + } finally { + await fd.close(); + } + await fs.rename(tmpPath, fullPath); + await this.syncDirectory(path.dirname(fullPath)); + } catch (error) { + await fs.unlink(tmpPath).catch(() => undefined); + throw error; + } + } - try { - entries = await fs.readdir(fullPath, { withFileTypes: true }); - } catch (error) { - throw new Error( - `Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` - ); - } + // Make the rename durable by fsync'ing the destination's parent directory. + // Skipped on Windows: fsync on a directory handle isn't supported there + private async syncDirectory(dir: string): Promise { + if (process.platform === "win32") { + return; + } + const fd = await fs.open(dir, "r"); + try { + await fd.sync(); + } finally { + await fd.close(); + } + } - for (const entry of entries) { - const entryName = entry.name; - const entryRelativePath = path.join(relativePath, entryName); + private async walkDirectory( + relativePath: string, + files: RelativePath[] + ): Promise { + const fullPath = path.join(this.basePath, relativePath); + let entries: Dirent[] = []; - if (entry.isDirectory()) { - await this.walkDirectory(entryRelativePath, files); - } else if (entry.isFile()) { - // Always return forward slashes - files.push(this.toUnixPath(entryRelativePath)); - } - } - } + try { + entries = await fs.readdir(fullPath, { withFileTypes: true }); + } catch (error) { + throw new Error( + `Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } - /** - * Convert a forward-slash path to native platform path separators - */ - private toNativePath(relativePath: string): string { - if (path.sep === "\\") { - return relativePath.replace(/\//g, "\\"); - } - return relativePath; - } + for (const entry of entries) { + const entryName = entry.name; + const entryRelativePath = path.join(relativePath, entryName); - /** - * Convert a native platform path to forward slashes - */ - private toUnixPath(nativePath: string): string { - if (path.sep === "\\") { - return nativePath.replace(/\\/g, "/"); - } - return nativePath; - } + if (entry.isDirectory()) { + await this.walkDirectory(entryRelativePath, files); + } else if (entry.isFile()) { + // Always return forward slashes + files.push(toUnixPath(entryRelativePath)); + } + } + } } diff --git a/frontend/local-client-cli/src/path-utils.test.ts b/frontend/local-client-cli/src/path-utils.test.ts new file mode 100644 index 00000000..13d33e6e --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.test.ts @@ -0,0 +1,60 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { matchesGlob, toUnixPath } from "./path-utils"; + +test("matchesGlob - exact match", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("other", ".DS_Store"), false); +}); + +test("matchesGlob - dir/** matches directory and contents", () => { + assert.equal(matchesGlob(".git", ".git/**"), true); + assert.equal(matchesGlob(".git/config", ".git/**"), true); + assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true); + assert.equal(matchesGlob(".gitignore", ".git/**"), false); +}); + +test("matchesGlob - * matches within a single segment", () => { + assert.equal(matchesGlob("foo.tmp", "*.tmp"), true); + assert.equal(matchesGlob("bar.tmp", "*.tmp"), true); + assert.equal(matchesGlob("foo.md", "*.tmp"), false); + assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false); +}); + +test("matchesGlob - **/*.ext matches at any depth", () => { + assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true); + assert.equal(matchesGlob("foo.md", "**/*.tmp"), false); +}); + +test("matchesGlob - ? matches single character", () => { + assert.equal(matchesGlob("a.md", "?.md"), true); + assert.equal(matchesGlob("ab.md", "?.md"), false); + assert.equal(matchesGlob(".md", "?.md"), false); +}); + +test("matchesGlob - dots are literal", () => { + assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true); + assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false); +}); + +test("matchesGlob - node_modules/** matches directory tree", () => { + assert.equal(matchesGlob("node_modules", "node_modules/**"), true); + assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true); + assert.equal( + matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"), + true + ); + assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false); +}); + +test("matchesGlob - **/ prefix matches zero or more segments", () => { + assert.equal(matchesGlob("test.log", "**/test.log"), true); + assert.equal(matchesGlob("dir/test.log", "**/test.log"), true); + assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true); +}); + +test("toUnixPath - forward slashes unchanged", () => { + assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz"); +}); diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts new file mode 100644 index 00000000..1ead144c --- /dev/null +++ b/frontend/local-client-cli/src/path-utils.ts @@ -0,0 +1,21 @@ +import * as path from "path"; + +// Convert a native platform path to forward slashes (no-op on non-Windows) +export function toUnixPath(nativePath: string): string { + return nativePath.split(path.sep).join(path.posix.sep); +} + +// Match a file path against a glob pattern. +// +// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches +// the directory `dir` itself, not only its descendants. The watcher feeds us +// a directory's relative path (e.g. ".git") at the same time it's about to +// recurse into it, and the natural way for users to write the ignore pattern +// is `.git/**` — under stdlib semantics that pattern would let the directory +// through and only block its children, defeating the prune. +export function matchesGlob(filePath: string, pattern: string): boolean { + if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { + return true; + } + return path.matchesGlob(filePath, pattern); +} diff --git a/frontend/local-client-cli/tsconfig.json b/frontend/local-client-cli/tsconfig.json index cfd2df7f..b07ec41a 100644 --- a/frontend/local-client-cli/tsconfig.json +++ b/frontend/local-client-cli/tsconfig.json @@ -1,8 +1,11 @@ { "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], + "target": "ESNext", + "module": "ESNext", + "lib": [ + "DOM", // to get `fetch` & `WebSocket` + "ES2024" + ], "outDir": "./dist", "rootDir": "./src", "strict": true, @@ -15,6 +18,5 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["dist"] } diff --git a/frontend/local-client-cli/webpack.config.js b/frontend/local-client-cli/webpack.config.js index e17754b2..9226b9dc 100644 --- a/frontend/local-client-cli/webpack.config.js +++ b/frontend/local-client-cli/webpack.config.js @@ -2,29 +2,32 @@ const path = require("path"); const webpack = require("webpack"); module.exports = { - entry: "./src/cli.ts", - target: "node", - mode: "production", - optimization: { - minimize: false - }, - module: { - rules: [ - { - test: /\.ts$/, - use: "ts-loader" - } - ] - }, - resolve: { - extensions: [".ts", ".js"] - }, - output: { - globalObject: "this", - filename: "cli.js", - path: path.resolve(__dirname, "dist") - }, - plugins: [ - new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) - ] + entry: { + cli: "./src/cli.ts", + healthcheck: "./src/healthcheck.ts" + }, + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } + ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "[name].js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] }; diff --git a/frontend/obsidian-plugin/.hotreload b/frontend/obsidian-plugin/.hotreload index e69de29b..8b137891 100644 --- a/frontend/obsidian-plugin/.hotreload +++ b/frontend/obsidian-plugin/.hotreload @@ -0,0 +1 @@ + diff --git a/frontend/obsidian-plugin/README.md b/frontend/obsidian-plugin/README.md index d7f694da..68e10a83 100644 --- a/frontend/obsidian-plugin/README.md +++ b/frontend/obsidian-plugin/README.md @@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti **Note:** The Obsidian API is still in early alpha and is subject to change at any time! This sample plugin demonstrates some of the basic functionality the plugin API can do. + - Adds a ribbon icon, which shows a Notice when clicked. - Adds a command "Open Sample Modal" which opens a Modal. - Adds a plugin setting tab to the settings page. @@ -57,36 +58,6 @@ Quick starting guide for new plugin devs: - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. - -## Funding URL - -You can include funding URLs where people who use your plugin can financially support it. - -The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: - -```json -{ - "fundingUrl": "https://buymeacoffee.com" -} -``` - -If you have multiple URLs, you can also do: - -```json -{ - "fundingUrl": { - "Buy Me a Coffee": "https://buymeacoffee.com", - "GitHub Sponsor": "https://github.com/sponsors", - "Patreon": "https://www.patreon.com/" - } -} -``` - ## API Documentation See https://github.com/obsidianmd/obsidian-api - - - - - diff --git a/frontend/obsidian-plugin/manifest.json b/frontend/obsidian-plugin/manifest.json index 7ad75bea..6f72fab0 100644 --- a/frontend/obsidian-plugin/manifest.json +++ b/frontend/obsidian-plugin/manifest.json @@ -1,10 +1,10 @@ { - "id": "vault-link", - "name": "VaultLink", - "version": "0.9.2", - "minAppVersion": "0.0.0", - "description": "Self-hosted synchronization and collaboration for your Vault.", - "author": "Andras Schmelczer", - "authorUrl": "https://schmelczer.dev", - "isDesktopOnly": false -} \ No newline at end of file + "id": "vault-link", + "name": "VaultLink", + "version": "0.14.0", + "minAppVersion": "0.0.0", + "description": "Self-hosted synchronization and collaboration for your Vault.", + "author": "Andras Schmelczer", + "authorUrl": "https://schmelczer.dev", + "isDesktopOnly": false +} diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index 7ff62a10..d24e537b 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -1,6 +1,6 @@ { "name": "vault-link-obsidian-plugin", - "version": "0.9.2", + "version": "0.14.0", "description": "This is a sample plugin for Obsidian (https://obsidian.md)", "main": "main.js", "scripts": { @@ -13,28 +13,25 @@ "author": "", "license": "MIT", "devDependencies": { - "@plausible-analytics/tracker": "^0.4.0", - "@sentry/browser": "^10.8.0", - "@types/node": "^24.8.1", + "@types/node": "^25.0.2", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", - "fs-extra": "^11.3.0", - "mini-css-extract-plugin": "^2.9.2", - "obsidian": "1.8.7", - "reconcile-text": "^0.5.0", + "fs-extra": "^11.3.2", + "mini-css-extract-plugin": "^2.9.4", + "obsidian": "1.11.0", + "reconcile-text": "^0.11.0", "resolve-url-loader": "^5.0.0", - "sass": "^1.91.0", - "sass-loader": "^16.0.5", + "sass": "^1.96.0", + "sass-loader": "^16.0.6", "sync-client": "file:../sync-client", - "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", + "terser-webpack-plugin": "^5.3.16", + "ts-loader": "^9.5.4", "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", + "tsx": "^4.21.0", + "typescript": "5.9.3", "url": "^0.11.4", - "virtual-scroller": "^1.13.1", - "webpack": "^5.99.9", + "webpack": "^5.103.0", "webpack-cli": "^6.0.1" } } diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts index 44407890..ceb8bc2a 100644 --- a/frontend/obsidian-plugin/src/obsidian-file-system.ts +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -1,176 +1,176 @@ import type { Stat, Vault, Workspace } from "obsidian"; import { MarkdownView, normalizePath } from "obsidian"; +import type { CursorPosition, TextWithCursors } from "sync-client"; import { - utils, - type FileSystemOperations, - type RelativePath + utils, + type FileSystemOperations, + type RelativePath } from "sync-client"; import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor"; -import type { TextWithCursors, CursorPosition } from "reconcile-text"; export class ObsidianFileSystemOperations implements FileSystemOperations { - public constructor( - private readonly vault: Vault, - private readonly workspace: Workspace - ) {} + public constructor( + private readonly vault: Vault, + private readonly workspace: Workspace + ) {} - public async listFilesRecursively( - root: RelativePath | undefined - ): Promise { - // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. - const allFiles = []; - const remainingFolders = [root ?? this.vault.getRoot().path]; + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { + // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. + const allFiles = []; + const remainingFolders = [root ?? this.vault.getRoot().path]; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - const folder = remainingFolders.pop(); - if (folder == undefined) { - break; - } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + const folder = remainingFolders.pop(); + if (folder == undefined) { + break; + } - // This would be a very bad idea to sync as it would mess with - // the integrity of the sync database. - if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) { - continue; - } + // This would be a very bad idea to sync as it would mess with + // the integrity of the sync database. + if (folder.endsWith(".obsidian/plugins/vault-link/data.json")) { + continue; + } - const files = await this.vault.adapter.list(normalizePath(folder)); - allFiles.push(...files.files); - remainingFolders.push(...files.folders); - } + const files = await this.vault.adapter.list(normalizePath(folder)); + allFiles.push(...files.files); + remainingFolders.push(...files.folders); + } - return allFiles; - } + return allFiles; + } - public async read(path: RelativePath): Promise { - path = normalizePath(path); - const view = this.workspace.getActiveViewOfType(MarkdownView); - if (view?.file?.path === path) { - return new TextEncoder().encode(view.editor.getValue()); - } + public async read(path: RelativePath): Promise { + path = normalizePath(path); + const view = this.workspace.getActiveViewOfType(MarkdownView); + if (view?.file?.path === path) { + return new TextEncoder().encode(view.editor.getValue()); + } - return new Uint8Array(await this.vault.adapter.readBinary(path)); - } + return new Uint8Array(await this.vault.adapter.readBinary(path)); + } - public async write(path: RelativePath, content: Uint8Array): Promise { - path = normalizePath(path); + public async write(path: RelativePath, content: Uint8Array): Promise { + path = normalizePath(path); - const view = this.workspace.getActiveViewOfType(MarkdownView); - if (view?.file?.path === path) { - const position = view.editor.getCursor(); - view.editor.setValue(new TextDecoder().decode(content)); - view.editor.setCursor(position); - return; - } + const view = this.workspace.getActiveViewOfType(MarkdownView); + if (view?.file?.path === path) { + const position = view.editor.getCursor(); + view.editor.setValue(new TextDecoder().decode(content)); + view.editor.setCursor(position); + return; + } - return this.vault.adapter.writeBinary( - path, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - content.buffer as ArrayBuffer - ); - } + return this.vault.adapter.writeBinary( + path, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + content.buffer as ArrayBuffer + ); + } - public async atomicUpdateText( - path: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - path = normalizePath(path); + public async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + path = normalizePath(path); - const view = this.workspace.getActiveViewOfType(MarkdownView); + const view = this.workspace.getActiveViewOfType(MarkdownView); - if (view?.file?.path === path) { - const text = view.editor.getValue(); + if (view?.file?.path === path) { + const text = view.editor.getValue(); - const cursors: CursorPosition[] = getSelectionsFromEditor( - view.editor - ).flatMap(({ id, start: anchor, end: head }) => [ - { - id: 2 * id, - position: anchor - }, - { - id: 2 * id + 1, - position: head - } - ]); + const cursors: CursorPosition[] = getSelectionsFromEditor( + view.editor + ).flatMap(({ id, start: anchor, end: head }) => [ + { + id: 2 * id, + position: anchor + }, + { + id: 2 * id + 1, + position: head + } + ]); - const result = updater({ - text, - cursors - }); + const result = updater({ + text, + cursors + }); - if (result.text === text) { - return text; - } + if (result.text === text) { + return text; + } - view.editor.setValue(result.text); + view.editor.setValue(result.text); - const selections = []; - for (let i = 0; i < result.cursors.length / 2; i++) { - const from = result.cursors[2 * i]; - const to = result.cursors[2 * i + 1]; - const { line: fromLine, column: fromColumn } = - utils.positionToLineAndColumn(result.text, from.position); + const selections = []; + for (let i = 0; i < result.cursors.length / 2; i++) { + const from = result.cursors[2 * i]; + const to = result.cursors[2 * i + 1]; + const { line: fromLine, column: fromColumn } = + utils.positionToLineAndColumn(result.text, from.position); - const { line: toLine, column: toColumn } = - utils.positionToLineAndColumn(result.text, to.position); + const { line: toLine, column: toColumn } = + utils.positionToLineAndColumn(result.text, to.position); - selections.push({ - anchor: { line: fromLine, ch: fromColumn }, - head: { line: toLine, ch: toColumn } - }); - } - view.editor.setSelections(selections); + selections.push({ + anchor: { line: fromLine, ch: fromColumn }, + head: { line: toLine, ch: toColumn } + }); + } + view.editor.setSelections(selections); - return result.text; - } + return result.text; + } - return this.vault.adapter.process( - path, - (text) => - updater({ - text, - cursors: [] - }).text - ); - } + return this.vault.adapter.process( + path, + (text) => + updater({ + text, + cursors: [] + }).text + ); + } - public async getFileSize(path: RelativePath): Promise { - return (await this.statFile(path)).size; - } + public async getFileSize(path: RelativePath): Promise { + return (await this.statFile(path)).size; + } - public async getModificationTime(path: RelativePath): Promise { - return new Date((await this.statFile(path)).mtime); - } + public async getModificationTime(path: RelativePath): Promise { + return new Date((await this.statFile(path)).mtime); + } - public async exists(path: RelativePath): Promise { - return this.vault.adapter.exists(normalizePath(path)); - } + public async exists(path: RelativePath): Promise { + return this.vault.adapter.exists(normalizePath(path)); + } - public async createDirectory(path: RelativePath): Promise { - return this.vault.adapter.mkdir(normalizePath(path)); - } + public async createDirectory(path: RelativePath): Promise { + return this.vault.adapter.mkdir(normalizePath(path)); + } - public async delete(path: RelativePath): Promise { - if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) { - return this.vault.adapter.remove(normalizePath(path)); - } - } + public async delete(path: RelativePath): Promise { + if (!(await this.vault.adapter.trashSystem(normalizePath(path)))) { + return this.vault.adapter.remove(normalizePath(path)); + } + } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - return this.vault.adapter.rename(oldPath, newPath); - } + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + return this.vault.adapter.rename(oldPath, newPath); + } - private async statFile(path: string): Promise { - const file = await this.vault.adapter.stat(normalizePath(path)); + private async statFile(path: string): Promise { + const file = await this.vault.adapter.stat(normalizePath(path)); - if (!file) { - throw new Error(`File not found: ${path}`); - } + if (!file) { + throw new Error(`File not found: ${path}`); + } - return file; - } + return file; + } } diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 25a03ff6..e222796b 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -1,282 +1,279 @@ import type { - MarkdownView, - Editor, - MarkdownFileInfo, - TAbstractFile, - WorkspaceLeaf + MarkdownView, + Editor, + MarkdownFileInfo, + TAbstractFile, + WorkspaceLeaf } from "obsidian"; -import { Platform, Plugin, TFile } from "obsidian"; +import { Notice, Platform, Plugin, TFile } from "obsidian"; import "../manifest.json"; import { HistoryView } from "./views/history/history-view"; import { StatusBar } from "./views/status-bar/status-bar"; import { LogsView } from "./views/logs/logs-view"; import { StatusDescription } from "./views/status-description/status-description"; -import * as Sentry from "@sentry/browser"; -import { init as plausibleInit } from "@plausible-analytics/tracker"; import { - SyncClient, - rateLimit, - DEFAULT_SETTINGS, - Logger, - debugging + SyncClient, + rateLimit, + DEFAULT_SETTINGS, + Logger, + debugging } from "sync-client"; import { ObsidianFileSystemOperations } from "./obsidian-file-system"; import { SyncSettingsTab } from "./views/settings/settings-tab"; import { EditorStatusDisplayManager } from "./views/editor-status-display-manager/editor-status-display-manager"; import { remoteCursorsTheme } from "./views/cursors/remote-cursor-theme"; import { - remoteCursorsPlugin, - RemoteCursorsPluginValue + remoteCursorsPlugin, + RemoteCursorsPluginValue } from "./views/cursors/remote-cursors-plugin"; import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-listener"; import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer"; const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250; +const IS_DEBUG_BUILD = process.env.NODE_ENV === "development"; export default class VaultLinkPlugin extends Plugin { - private readonly disposables: (() => unknown)[] = []; + private readonly rateLimitedUpdatesPerFile = new Map< + string, + () => Promise + >(); - private settingsTab: SyncSettingsTab | undefined; - private client!: SyncClient; - private readonly rateLimitedUpdatesPerFile = new Map< - string, - () => Promise - >(); + private readonly syncClient: SyncClient | undefined; + private settingsTab: SyncSettingsTab | undefined; - public async onload(): Promise { - DEFAULT_SETTINGS.ignorePatterns.push( - ".obsidian/**", - ".git/**", - ".trash/**" - ); + public async onload(): Promise { + this.app.workspace.onLayoutReady(async () => { + // eslint-disable-next-line + if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) { + new Notice( + "Another instance of VaultLink is already running. Please disable the duplicate instance." + ); + throw new Error("VaultLink instance already running"); + } + // eslint-disable-next-line + (globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this; - plausibleInit({ - domain: "vault-link", - endpoint: "https://stats.schmelczer.dev/status", - autoCapturePageviews: true, - captureOnLocalhost: true, - logging: true - }); + const client = await this.createSyncClient(); - Sentry.init({ - dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1", - skipBrowserExtensionCheck: false - }); + this.registerObsidianExtensions(client); - const onError = (event: ErrorEvent): void => { - Sentry.captureException(event.error, { - extra: { - message: event.message, - filename: event.filename, - lineno: event.lineno, - colno: event.colno - } - }); - }; - window.addEventListener("error", onError); - this.disposables.push(() => { - window.removeEventListener("error", onError); - }); + this.registerEditorEvents(client); - const onUnhandledRejection = (event: PromiseRejectionEvent): void => { - Sentry.captureException(event.reason); - }; - window.addEventListener("unhandledrejection", onUnhandledRejection); - this.disposables.push(() => { - window.removeEventListener( - "unhandledrejection", - onUnhandledRejection - ); - }); + this.register(async () => { + await client.waitUntilFinished(); + await client.destroy(); + }); - const isDebugBuild = process.env.NODE_ENV === "development"; - const debugOptions = isDebugBuild - ? { - fetch: debugging.slowFetchFactory(1), - webSocket: debugging.slowWebSocketFactory(1, new Logger()) - } - : {}; + await client.start(); + }); + } - this.client = await SyncClient.create({ - fs: new ObsidianFileSystemOperations( - this.app.vault, - this.app.workspace - ), - persistence: { - load: this.loadData.bind(this), - save: this.saveData.bind(this) - }, - nativeLineEndings: Platform.isWin ? "\r\n" : "\n", - ...debugOptions - }); + public onUserEnable(): void { + new Notice( + "VaultLink has been enabled, check out the docs for tips on getting started!" + ); + void this.activateView(HistoryView.TYPE).catch((e: unknown) => { + this.syncClient?.logger.error( + `Failed to open history view on enable: ${e}` + ); + }); + void this.activateView(LogsView.TYPE).catch((e: unknown) => { + this.syncClient?.logger.error( + `Failed to open logs view on enable: ${e}` + ); + }); + this.openSettings(); + } - if (isDebugBuild) { - debugging.logToConsole(this.client); - } + public openSettings(): void { + // eslint-disable-next-line + (this.app as any).setting.open(); // this is undocumented + // eslint-disable-next-line + (this.app as any).setting.openTab(this.settingsTab); // this is undocumented + } - const statusDescription = new StatusDescription(this.client); + public closeSettings(): void { + // eslint-disable-next-line + (this.app as any).setting.close(); // this is undocumented + } - this.settingsTab = new SyncSettingsTab({ - app: this.app, - plugin: this, - syncClient: this.client, - statusDescription - }); - this.addSettingTab(this.settingsTab); + public async activateView(type: string): Promise { + const { workspace } = this.app; - new StatusBar(this, this.client); + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(type); - this.registerView( - HistoryView.TYPE, - (leaf) => new HistoryView(this.client, leaf) - ); + if (leaves.length > 0) { + [leaf] = leaves; + } else { + leaf = workspace.getRightLeaf(false); + await leaf?.setViewState({ type: type, active: true }); + } - this.registerView( - LogsView.TYPE, - (leaf) => new LogsView(this.client, leaf) - ); + if (leaf) { + await workspace.revealLeaf(leaf); + } + } - this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); + private async createSyncClient(): Promise { + DEFAULT_SETTINGS.ignorePatterns.push( + ".obsidian/**", + ".git/**", + ".trash/**", + "**/.DS_Store" + ); - this.client.addRemoteCursorsUpdateListener((cursors) => { - RemoteCursorsPluginValue.setCursors(cursors, this.app); - renderCursorsInFileExplorer(cursors, this.app); - }); + const client = await SyncClient.create({ + fs: new ObsidianFileSystemOperations( + this.app.vault, + this.app.workspace + ), + persistence: { + load: this.loadData.bind(this), + save: this.saveData.bind(this) + }, + nativeLineEndings: Platform.isWin ? "\r\n" : "\n", + ...(IS_DEBUG_BUILD + ? { + fetch: debugging.slowFetchFactory(1), + webSocket: debugging.slowWebSocketFactory(1, new Logger()) + } + : {}) + }); - const cursorListener = new LocalCursorUpdateListener( - this.client, - this.app.workspace - ); - this.disposables.push(() => { - cursorListener.dispose(); - }); + if (IS_DEBUG_BUILD) { + debugging.logToConsole(client.logger); + } - this.app.workspace.updateOptions(); + return client; + } - this.addRibbonIcon( - HistoryView.ICON, - "Open VaultLink events", - async (_: MouseEvent) => this.activateView(HistoryView.TYPE) - ); + private registerObsidianExtensions(client: SyncClient): void { + const statusDescription = new StatusDescription(client); - this.addRibbonIcon( - LogsView.ICON, - "Open VaultLink logs", - async (_: MouseEvent) => this.activateView(LogsView.TYPE) - ); + this.settingsTab = new SyncSettingsTab({ + app: this.app, + plugin: this, + syncClient: client, + statusDescription + }); + this.addSettingTab(this.settingsTab); - this.app.workspace.onLayoutReady(async () => { - this.registerEditorEvents(); - await this.client.start(); + new StatusBar(this, client); - const editorStatusDisplayManager = new EditorStatusDisplayManager( - this, - this.app.workspace, - this.client - ); - this.disposables.push(() => { - editorStatusDisplayManager.stop(); - }); - }); - } + this.registerView(HistoryView.TYPE, (leaf) => { + const view = new HistoryView(client, leaf); + this.register(async () => view.onClose()); + return view; + }); - public onunload(): void { - this.client.waitAndStop().catch((err: unknown) => { - this.client.logger.error( - `Error while stopping the sync client: ${err}` - ); - }); - this.disposables.forEach((disposable) => { - disposable(); - }); - } + this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf)); - public openSettings(): void { - // eslint-disable-next-line - (this.app as any).setting.open(); // this is undocumented - // eslint-disable-next-line - (this.app as any).setting.openTab(this.settingsTab); // this is undocumented - } + this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); - public closeSettings(): void { - // eslint-disable-next-line - (this.app as any).setting.close(); // this is undocumented - } + client.onRemoteCursorsUpdated.add((cursors) => { + RemoteCursorsPluginValue.setCursors(cursors, this.app); + renderCursorsInFileExplorer(cursors, this.app); + }); - public async activateView(type: string): Promise { - const { workspace } = this.app; + const cursorListener = new LocalCursorUpdateListener( + client, + this.app.workspace + ); + this.register(() => { + cursorListener.dispose(); + }); - let leaf: WorkspaceLeaf | null = null; - const leaves = workspace.getLeavesOfType(type); + this.app.workspace.updateOptions(); - if (leaves.length > 0) { - [leaf] = leaves; - } else { - leaf = workspace.getRightLeaf(false); - await leaf?.setViewState({ type: type, active: true }); - } + this.addRibbonIcons(); - if (leaf) { - await workspace.revealLeaf(leaf); - } - } + const editorStatusDisplayManager = new EditorStatusDisplayManager( + this, + this.app.workspace, + client + ); + this.register(() => { + editorStatusDisplayManager.dispose(); + }); - private registerEditorEvents(): void { - [ - this.app.workspace.on( - "editor-change", - async ( - _editor: Editor, - info: MarkdownView | MarkdownFileInfo - ) => { - const { file } = info; - if (file) { - await this.rateLimitedUpdate(file.path); - } - } - ), - this.app.vault.on("create", async (file: TAbstractFile) => { - if (file instanceof TFile) { - await this.client.syncLocallyCreatedFile(file.path); - } - }), - this.app.vault.on("modify", async (file: TAbstractFile) => { - if (file instanceof TFile) { - await this.rateLimitedUpdate(file.path); - } - }), - this.app.vault.on("delete", async (file: TAbstractFile) => { - await this.client.syncLocallyDeletedFile(file.path); - }), - this.app.vault.on( - "rename", - async (file: TAbstractFile, oldPath: string) => { - if (file instanceof TFile) { - await this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: file.path - }); - } - } - ) - ].forEach((event) => { - this.registerEvent(event); - }); - } + this.register(() => { + // eslint-disable-next-line + (globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null; + }); + } - private async rateLimitedUpdate(path: string): Promise { - if (!this.rateLimitedUpdatesPerFile.has(path)) { - this.rateLimitedUpdatesPerFile.set( - path, - rateLimit( - async () => - this.client.syncLocallyUpdatedFile({ - relativePath: path - }), - MIN_WAIT_BETWEEN_UPDATES_IN_MS - ) - ); - } - await this.rateLimitedUpdatesPerFile.get(path)?.(); - } + private addRibbonIcons(): void { + this.addRibbonIcon( + HistoryView.ICON, + "Open VaultLink events", + async (_: MouseEvent) => this.activateView(HistoryView.TYPE) + ); + + this.addRibbonIcon( + LogsView.ICON, + "Open VaultLink logs", + async (_: MouseEvent) => this.activateView(LogsView.TYPE) + ); + } + + private registerEditorEvents(client: SyncClient): void { + [ + this.app.workspace.on( + "editor-change", + async ( + _editor: Editor, + info: MarkdownView | MarkdownFileInfo + ) => { + const { file } = info; + if (file) { + await this.rateLimitedUpdate(file.path, client); + } + } + ), + this.app.vault.on("create", (file: TAbstractFile) => { + if (file instanceof TFile) { + client.syncLocallyCreatedFile(file.path); + } + }), + this.app.vault.on("modify", async (file: TAbstractFile) => { + if (file instanceof TFile) { + await this.rateLimitedUpdate(file.path, client); + } + }), + this.app.vault.on("delete", (file: TAbstractFile) => { + client.syncLocallyDeletedFile(file.path); + }), + this.app.vault.on( + "rename", + (file: TAbstractFile, oldPath: string) => { + if (file instanceof TFile) { + client.syncLocallyUpdatedFile({ + oldPath, + relativePath: file.path + }); + } + } + ) + ].forEach((event) => { + this.registerEvent(event); + }); + } + + private async rateLimitedUpdate( + path: string, + client: SyncClient + ): Promise { + if (!this.rateLimitedUpdatesPerFile.has(path)) { + this.rateLimitedUpdatesPerFile.set( + path, + rateLimit(async () => { + client.syncLocallyUpdatedFile({ + relativePath: path + }); + }, MIN_WAIT_BETWEEN_UPDATES_IN_MS) + ); + } + await this.rateLimitedUpdatesPerFile.get(path)?.(); + } } diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss b/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss index 90918b55..ebdd7730 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss @@ -12,4 +12,4 @@ font-size: var(--font-smallest); font-style: italic; } -} \ No newline at end of file +} diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts index be71c058..409402a4 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -2,54 +2,56 @@ import "./file-explorer.scss"; import type { App, View } from "obsidian"; import { - utils, - type MaybeOutdatedClientCursors, - type RelativePath + utils, + type MaybeOutdatedClientCursors, + type RelativePath } from "sync-client"; const REMOTE_USER_CONTAINER_CLASS = "remote-users"; export function renderCursorsInFileExplorer( - cursors: MaybeOutdatedClientCursors[], - app: App + cursors: MaybeOutdatedClientCursors[], + app: App ): void { - const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); - if (fileExplorers.length == 0) return; + const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); + if (fileExplorers.length == 0) { + return; + } - const [fileExplorer] = fileExplorers; + const [fileExplorer] = fileExplorers; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const fileExplorerView: View & { - fileItems: Record; // it's an internal API - } = fileExplorer.view as any; // eslint-disable-line + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const fileExplorerView: View & { + fileItems: Record; // it's an internal API + } = fileExplorer.view as any; // eslint-disable-line - for (const key in fileExplorerView.fileItems) { - const element = - fileExplorerView.fileItems[key].el.querySelector(".tree-item-self"); + for (const key in fileExplorerView.fileItems) { + const element = + fileExplorerView.fileItems[key].el.querySelector(".tree-item-self"); - const customElement = createDiv( - { - cls: REMOTE_USER_CONTAINER_CLASS - }, - (parent) => { - cursors.forEach((cursor) => { - cursor.documentsWithCursors.forEach((document) => { - if (document.relative_path === key) { - parent.appendChild( - createSpan({ - text: cursor.userName, - attr: { - style: `border-color: ${utils.getRandomColor(cursor.userName)}` - } - }) - ); - } - }); - }); - } - ); + const customElement = createDiv( + { + cls: REMOTE_USER_CONTAINER_CLASS + }, + (parent) => { + cursors.forEach((cursor) => { + cursor.documentsWithCursors.forEach((document) => { + if (document.relativePath.startsWith(key)) { + parent.appendChild( + createSpan({ + text: cursor.userName, + attr: { + style: `border-color: ${utils.getRandomColor(cursor.userName)}` + } + }) + ); + } + }); + }); + } + ); - element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove(); - element?.appendChild(customElement); - } + element?.querySelector("." + REMOTE_USER_CONTAINER_CLASS)?.remove(); + element?.appendChild(customElement); + } } diff --git a/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts b/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts index 1635b930..3ddb60a3 100644 --- a/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts +++ b/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts @@ -2,16 +2,16 @@ import type { Editor } from "obsidian"; import { utils } from "sync-client"; export interface Selection { - id: number; - start: number; - end: number; + id: number; + start: number; + end: number; } export function getSelectionsFromEditor(editor: Editor): Selection[] { - const text = editor.getValue(); - return editor.listSelections().map(({ anchor, head }, i) => ({ - id: i, - start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch), - end: utils.lineAndColumnToPosition(text, head.line, head.ch) - })); + const text = editor.getValue(); + return editor.listSelections().map(({ anchor, head }, i) => ({ + id: i, + start: utils.lineAndColumnToPosition(text, anchor.line, anchor.ch), + end: utils.lineAndColumnToPosition(text, head.line, head.ch) + })); } diff --git a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts index da67c70d..f1dba005 100644 --- a/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -5,46 +5,46 @@ import type { Selection } from "./get-selections-from-editor"; import { getSelectionsFromEditor } from "./get-selections-from-editor"; export class LocalCursorUpdateListener { - private static readonly UPDATE_INTERVAL_MS = 50; - private readonly eventHandle: NodeJS.Timeout; + private static readonly UPDATE_INTERVAL_MS = 50; + private readonly eventHandle: NodeJS.Timeout; - public constructor( - private readonly client: SyncClient, - private readonly workspace: Workspace - ) { - this.eventHandle = setInterval(() => { - this.updateAllSelections(); - }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); - } + public constructor( + private readonly client: SyncClient, + private readonly workspace: Workspace + ) { + this.eventHandle = setInterval(() => { + this.updateAllSelections(); + }, LocalCursorUpdateListener.UPDATE_INTERVAL_MS); + } - public dispose(): void { - clearInterval(this.eventHandle); - } + public dispose(): void { + clearInterval(this.eventHandle); + } - private updateAllSelections(): void { - const currentCursors = this.getAllSelections(); - this.client - .updateLocalCursors(currentCursors) - .catch((error: unknown) => { - this.client.logger.error( - `Failed to update local cursors: ${error}` - ); - }); - } + private updateAllSelections(): void { + const currentCursors = this.getAllSelections(); + this.client + .updateLocalCursors(currentCursors) + .catch((error: unknown) => { + this.client.logger.error( + `Failed to update local cursors: ${error}` + ); + }); + } - private getAllSelections(): Record { - const cursors: Record = {}; - this.workspace - .getLeavesOfType("markdown") - .map((leaf) => leaf.view) - .filter((view) => view instanceof MarkdownView) - .forEach((view) => { - const { file } = view; - if (!file) { - return; - } - cursors[file.path] = getSelectionsFromEditor(view.editor); - }); - return cursors; - } + private getAllSelections(): Record { + const cursors: Record = {}; + this.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .forEach((view) => { + const { file } = view; + if (!file) { + return; + } + cursors[file.path] = getSelectionsFromEditor(view.editor); + }); + return cursors; + } } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts index 3af2692d..e508b42f 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts @@ -4,60 +4,60 @@ const CARET_WIDTH = 2; const DOT_RADIUS = 4; export const remoteCursorsTheme = EditorView.baseTheme({ - ".selection-caret": { - position: "relative" - }, + ".selection-caret": { + position: "relative" + }, - ".selection-caret > *": { - position: "absolute", - backgroundColor: "inherit" - }, + ".selection-caret > *": { + position: "absolute", + backgroundColor: "inherit" + }, - ".selection-caret > .stick": { - left: 0, - top: 0, - transform: "translateX(-50%)", - width: `${CARET_WIDTH}px`, - height: "100%", - display: "block", - borderRadius: `${CARET_WIDTH / 2}px`, - animation: "blink-stick 1s steps(1) infinite" - }, + ".selection-caret > .stick": { + left: 0, + top: 0, + transform: "translateX(-50%)", + width: `${CARET_WIDTH}px`, + height: "100%", + display: "block", + borderRadius: `${CARET_WIDTH / 2}px`, + animation: "blink-stick 1s steps(1) infinite" + }, - "@keyframes blink-stick": { - "0%, 100%": { opacity: 1 }, - "50%": { opacity: 0 } - }, + "@keyframes blink-stick": { + "0%, 100%": { opacity: 1 }, + "50%": { opacity: 0 } + }, - ".selection-caret > .dot": { - borderRadius: "50%", - width: `${DOT_RADIUS * 2}px`, - height: `${DOT_RADIUS * 2}px`, - top: `-${DOT_RADIUS}px`, - left: `-${DOT_RADIUS}px`, - transition: "transform .3s ease-in-out", - transformOrigin: "bottom center", - boxSizing: "border-box" - }, + ".selection-caret > .dot": { + borderRadius: "50%", + width: `${DOT_RADIUS * 2}px`, + height: `${DOT_RADIUS * 2}px`, + top: `-${DOT_RADIUS}px`, + left: `-${DOT_RADIUS}px`, + transition: "transform .3s ease-in-out", + transformOrigin: "bottom center", + boxSizing: "border-box" + }, - ".selection-caret:hover > .dot": { - transform: "scale(0)" - }, + ".selection-caret:hover > .dot": { + transform: "scale(0)" + }, - ".selection-caret > .info": { - top: "-1.3em", - left: `-${CARET_WIDTH / 2}px`, - fontSize: "0.9em", - userSelect: "none", - color: "white", - padding: "0 2px", - transition: "opacity .3s ease-in-out", - opacity: 0, - whiteSpace: "nowrap", - borderRadius: "3px 3px 3px 0" - }, + ".selection-caret > .info": { + top: "-1.3em", + left: `-${CARET_WIDTH / 2}px`, + fontSize: "0.9em", + userSelect: "none", + color: "white", + padding: "0 2px", + transition: "opacity .3s ease-in-out", + opacity: 0, + whiteSpace: "nowrap", + borderRadius: "3px 3px 3px 0" + }, - ".selection-caret:hover > .info": { - opacity: 1 - } + ".selection-caret:hover > .info": { + opacity: 1 + } }); diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts index e3273484..7f31ac00 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts @@ -1,46 +1,46 @@ import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; import { - ViewUpdate, - ViewPlugin, - Decoration, - WidgetType + ViewUpdate, + ViewPlugin, + Decoration, + WidgetType } from "@codemirror/view"; import type { PluginValue, DecorationSet, EditorView } from "@codemirror/view"; export class RemoteCursorWidget extends WidgetType { - public constructor( - private readonly color: string, - private readonly name: string - ) { - super(); - } + public constructor( + private readonly color: string, + private readonly name: string + ) { + super(); + } - public toDOM(editor: EditorView): HTMLElement { - return editor.contentDOM.createEl( - "span", - { - cls: "selection-caret", - attr: { - style: `background-color: ${this.color}; border-color: ${this.color}` - } - }, - (span) => { - span.createEl("div", { - cls: "stick" - }); - span.createEl("div", { - cls: "dot" - }); - span.createEl("div", { - cls: "info", - text: this.name - }); - } - ); - } + public toDOM(editor: EditorView): HTMLElement { + return editor.contentDOM.createEl( + "span", + { + cls: "selection-caret", + attr: { + style: `background-color: ${this.color}; border-color: ${this.color}` + } + }, + (span) => { + span.createEl("div", { + cls: "stick" + }); + span.createEl("div", { + cls: "dot" + }); + span.createEl("div", { + cls: "info", + text: this.name + }); + } + ); + } - public eq(other: RemoteCursorWidget): boolean { - return other.color === this.color && other.name === this.name; - } + public eq(other: RemoteCursorWidget): boolean { + return other.color === this.color && other.name === this.name; + } } diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index 86ddd6cd..4200a72a 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -3,17 +3,17 @@ import { RangeSet } from "@codemirror/state"; import { ViewPlugin, Decoration } from "@codemirror/view"; import type { - PluginValue, - DecorationSet, - EditorView, - ViewUpdate + PluginValue, + DecorationSet, + EditorView, + ViewUpdate } from "@codemirror/view"; import { RemoteCursorWidget } from "./remote-cursor-widget"; import type { RelativePath } from "sync-client"; import { - utils, - type CursorSpan, - type MaybeOutdatedClientCursors + utils, + type CursorSpan, + type MaybeOutdatedClientCursors } from "sync-client"; import type { App } from "obsidian"; import { MarkdownView } from "obsidian"; @@ -25,241 +25,242 @@ import { reconcileWithHistory } from "reconcile-text"; const forceUpdate = StateEffect.define(); export class RemoteCursorsPluginValue implements PluginValue { - private static cursors: { - name: string; - path: string; - span: CursorSpan; - deviceId: string; - isOutdated: boolean; - }[] = []; + private static cursors: { + name: string; + path: string; + span: CursorSpan; + deviceId: string; + isOutdated: boolean; + }[] = []; - private static app: App; - public decorations: DecorationSet = RangeSet.of([]); + private static app?: App; + public decorations: DecorationSet = RangeSet.of([]); - public static setCursors( - clients: MaybeOutdatedClientCursors[], - app: App - ): void { - RemoteCursorsPluginValue.app = app; - RemoteCursorsPluginValue.cursors = [ - ...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) => - clients.some( - (client) => - client.deviceId === deviceId && client.isOutdated - ) - ), - ...clients - .filter( - ({ isOutdated, deviceId }) => - !isOutdated || - RemoteCursorsPluginValue.cursors.every( - (c) => deviceId !== c.deviceId - ) - ) - .flatMap((client) => { - const clientCursors = client.documentsWithCursors; - return clientCursors.flatMap((cursor) => - cursor.cursors.map((span) => ({ - name: client.userName, - path: cursor.relative_path, - deviceId: client.deviceId, - isOutdated: client.isOutdated, - span: { ...span } - })) - ); - }) - ]; + public static setCursors( + clients: MaybeOutdatedClientCursors[], + app: App + ): void { + RemoteCursorsPluginValue.app = app; + RemoteCursorsPluginValue.cursors = [ + ...RemoteCursorsPluginValue.cursors.filter(({ deviceId }) => + clients.some( + (client) => + client.deviceId === deviceId && client.isOutdated + ) + ), + ...clients + .filter( + ({ isOutdated, deviceId }) => + !isOutdated || + RemoteCursorsPluginValue.cursors.every( + (c) => deviceId !== c.deviceId + ) + ) + .flatMap((client) => { + const clientCursors = client.documentsWithCursors; + return clientCursors.flatMap((cursor) => + cursor.cursors.map((span) => ({ + name: client.userName, + path: cursor.relativePath, + deviceId: client.deviceId, + isOutdated: client.isOutdated, + span: { ...span } + })) + ); + }) + ]; - app.workspace - .getLeavesOfType("markdown") - .map((leaf) => leaf.view) - .filter((view) => view instanceof MarkdownView) - .forEach((view) => { - // @ts-expect-error, not typed - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const editor = view.editor.cm as EditorView; + app.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .forEach((view) => { + // @ts-expect-error, not typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const editor = view.editor.cm as EditorView; - editor.dispatch({ - effects: [forceUpdate.of(null)] - }); - }); - } + editor.dispatch({ + effects: [forceUpdate.of(null)] + }); + }); + } - private static findFileForEditor( - editor: EditorView - ): RelativePath | undefined { - return RemoteCursorsPluginValue.app.workspace - .getLeavesOfType("markdown") - .map((leaf) => leaf.view) - .filter((view) => view instanceof MarkdownView) - .flatMap((view) => { - // @ts-expect-error, not typed - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((view.editor.cm as EditorView) !== editor) { - return []; - } + private static findFileForEditor( + editor: EditorView + ): RelativePath | undefined { + return RemoteCursorsPluginValue.app?.workspace + .getLeavesOfType("markdown") + .map((leaf) => leaf.view) + .filter((view) => view instanceof MarkdownView) + .flatMap((view) => { + // @ts-expect-error, not typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + if ((view.editor.cm as EditorView) !== editor) { + return []; + } - const { file } = view; - if (!file) { - return; - } + const { file } = view; + if (!file) { + return; + } - return [file.path]; - }) - .first(); - } + return [file.path]; + }) + .first(); + } - private static interpolateRemoteCursorPositions( - original: string, - edited: string - ): void { - if ( - original === edited || - RemoteCursorsPluginValue.cursors.length === 0 - ) { - return; - } + private static interpolateRemoteCursorPositions( + original: string, + edited: string + ): void { + if ( + original === edited || + RemoteCursorsPluginValue.cursors.length === 0 + ) { + return; + } - const updatedPositions: number[] = []; - const reconciled = reconcileWithHistory( - original, - { - text: original, - cursors: RemoteCursorsPluginValue.cursors.flatMap( - ({ span }, i) => [ - { id: i * 2, position: span.start }, - { id: i * 2 + 1, position: span.end } - ] - ) - }, - edited - ); + const updatedPositions: number[] = []; + const reconciled = reconcileWithHistory( + original, + { + text: original, + cursors: RemoteCursorsPluginValue.cursors.flatMap( + ({ span }, i) => [ + { id: i * 2, position: span.start }, + { id: i * 2 + 1, position: span.end } + ] + ) + }, + edited, + "Markdown" + ); - reconciled.cursors.forEach(({ id, position }) => { - const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor( - position, - reconciled.history - ); - if (whereToJump !== null) { - updatedPositions[id] = whereToJump; - } else { - updatedPositions[id] = position; - } - }); + reconciled.cursors.forEach(({ id, position }) => { + const whereToJump = RemoteCursorsPluginValue.findWhereToMoveCursor( + position, + reconciled.history + ); + if (whereToJump !== null) { + updatedPositions[id] = whereToJump; + } else { + updatedPositions[id] = position; + } + }); - RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => { - span.start = updatedPositions[i * 2]; - span.end = updatedPositions[i * 2 + 1]; - }); - } + RemoteCursorsPluginValue.cursors.forEach(({ span }, i) => { + span.start = updatedPositions[i * 2]; + span.end = updatedPositions[i * 2 + 1]; + }); + } - private static findWhereToMoveCursor( - cursor: number, - spans: SpanWithHistory[] - ): number | null { - let position = 0; - for (const span of spans) { - // left and origin are the same - if (position === cursor && span.history === "AddedFromRight") { - return position + span.text.length; - } - position += span.text.length; - if (position === cursor && span.history === "RemovedFromRight") { - return position - span.text.length; - } - } + private static findWhereToMoveCursor( + cursor: number, + spans: SpanWithHistory[] + ): number | null { + let position = 0; + for (const span of spans) { + // left and origin are the same + if (position === cursor && span.history === "AddedFromRight") { + return position + span.text.length; + } + position += span.text.length; + if (position === cursor && span.history === "RemovedFromRight") { + return position - span.text.length; + } + } - return null; - } + return null; + } - public update(update: ViewUpdate): void { - const original = update.startState.doc.toString(); - const edited = update.state.doc.toString(); + public update(update: ViewUpdate): void { + const original = update.startState.doc.toString(); + const edited = update.state.doc.toString(); - RemoteCursorsPluginValue.interpolateRemoteCursorPositions( - original, - edited - ); + RemoteCursorsPluginValue.interpolateRemoteCursorPositions( + original, + edited + ); - const decorations: Range[] = []; - const relative_path = RemoteCursorsPluginValue.findFileForEditor( - update.view - ); - RemoteCursorsPluginValue.cursors - .filter(({ path }) => path == relative_path) - .forEach(({ name, span: { start, end } }) => { - const color = utils.getRandomColor(name); - const startLine = update.view.state.doc.lineAt(start); - const endLine = update.view.state.doc.lineAt(end); + const decorations: Range[] = []; + const relative_path = RemoteCursorsPluginValue.findFileForEditor( + update.view + ); + RemoteCursorsPluginValue.cursors + .filter(({ path }) => path == relative_path) + .forEach(({ name, span: { start, end } }) => { + const color = utils.getRandomColor(name); + const startLine = update.view.state.doc.lineAt(start); + const endLine = update.view.state.doc.lineAt(end); - const attributes = { - style: `background-color: ${color};` - }; + const attributes = { + style: `background-color: ${color};` + }; - if (startLine.number === endLine.number) { - // selected content in a single line. - decorations.push({ - from: start, - to: end, - value: Decoration.mark({ - attributes - }) - }); - } else { - // selected content in multiple lines - // first, render text-selection in the first line - decorations.push({ - from: start, - to: startLine.from + startLine.length, - value: Decoration.mark({ - attributes - }) - }); + if (startLine.number === endLine.number) { + // selected content in a single line. + decorations.push({ + from: start, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } else { + // selected content in multiple lines + // first, render text-selection in the first line + decorations.push({ + from: start, + to: startLine.from + startLine.length, + value: Decoration.mark({ + attributes + }) + }); - // render text-selection in the lines between the first and last line - for ( - let i = startLine.number + 1; - i < endLine.number; - i++ - ) { - const currentLine = update.view.state.doc.line(i); - decorations.push({ - from: currentLine.from, - to: currentLine.to, - value: Decoration.mark({ - attributes - }) - }); - } + // render text-selection in the lines between the first and last line + for ( + let i = startLine.number + 1; + i < endLine.number; + i++ + ) { + const currentLine = update.view.state.doc.line(i); + decorations.push({ + from: currentLine.from, + to: currentLine.to, + value: Decoration.mark({ + attributes + }) + }); + } - // render text-selection in the last line - decorations.push({ - from: endLine.from, - to: end, - value: Decoration.mark({ - attributes - }) - }); - } + // render text-selection in the last line + decorations.push({ + from: endLine.from, + to: end, + value: Decoration.mark({ + attributes + }) + }); + } - decorations.push({ - from: end, - to: end, - value: Decoration.widget({ - side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection - block: false, - widget: new RemoteCursorWidget(color, name) - }) - }); - }); + decorations.push({ + from: end, + to: end, + value: Decoration.widget({ + side: end - start > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection + block: false, + widget: new RemoteCursorWidget(color, name) + }) + }); + }); - this.decorations = Decoration.set(decorations, true); - } + this.decorations = Decoration.set(decorations, true); + } } export const remoteCursorsPlugin = ViewPlugin.fromClass( - RemoteCursorsPluginValue, - { - decorations: (v) => v.decorations - } + RemoteCursorsPluginValue, + { + decorations: (v) => v.decorations + } ); diff --git a/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss index a430ac3b..8cc530d2 100644 --- a/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss +++ b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss @@ -1,43 +1,43 @@ .vault-link-sync-status { - position: absolute; - right: var(--size-4-4); - top: var(--size-4-2); - opacity: 0.7; - cursor: pointer; + position: absolute; + right: var(--size-4-4); + top: var(--size-4-2); + opacity: 0.7; + cursor: pointer; - > span { - opacity: 0; - position: absolute; - min-width: 200px; - text-align: right; - padding-right: var(--size-2-2); + > span { + opacity: 0; + position: absolute; + min-width: 200px; + text-align: right; + padding-right: var(--size-2-2); - top: 50%; - left: 0; - transform: translateY(-50%) translateX(-100%) translateY(-2px); - transition: opacity 200ms; - } + top: 50%; + left: 0; + transform: translateY(-50%) translateX(-100%) translateY(-2px); + transition: opacity 200ms; + } - &:hover { - > span { - opacity: 1; - } - } + &:hover { + > span { + opacity: 1; + } + } - > .icon { - line-height: 0; - } + > .icon { + line-height: 0; + } - &.loading > .icon { - animation: spin 2s linear infinite; + &.loading > .icon { + animation: spin 2s linear infinite; - @keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } - } + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + } } diff --git a/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts index 5075b847..1010c7e3 100644 --- a/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts +++ b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts @@ -7,91 +7,91 @@ import type VaultLinkPlugin from "src/vault-link-plugin"; import { HistoryView } from "../history/history-view"; export class EditorStatusDisplayManager { - private static readonly UPDATE_INTERVAL_IN_MS = 100; + private static readonly UPDATE_INTERVAL_IN_MS = 100; - private readonly intervalId: NodeJS.Timeout; - private readonly lastStatuses = new Map(); + private readonly intervalId: NodeJS.Timeout; + private readonly lastStatuses = new Map(); - public constructor( - private readonly plugin: VaultLinkPlugin, - private readonly workspace: Workspace, - private readonly client: SyncClient - ) { - this.intervalId = setInterval(() => { - this.updateEditorStatusDisplay(); - }, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS); - } + public constructor( + private readonly plugin: VaultLinkPlugin, + private readonly workspace: Workspace, + private readonly client: SyncClient + ) { + this.intervalId = setInterval(() => { + this.updateEditorStatusDisplay(); + }, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS); + } - public stop(): void { - clearInterval(this.intervalId); - } + public dispose(): void { + clearInterval(this.intervalId); + } - private updateEditorStatusDisplay(): void { - this.workspace.iterateAllLeaves((leaf) => { - if (leaf.view instanceof FileView) { - const filePath = leaf.view.file?.path; - if (filePath == null) { - return; - } + private updateEditorStatusDisplay(): void { + this.workspace.iterateAllLeaves((leaf) => { + if (leaf.view instanceof FileView) { + const filePath = leaf.view.file?.path; + if (filePath == null) { + return; + } - const element = this.getElementFromLeaf(leaf.view); - if (element == null) { - return; - } + const element = this.getElementFromLeaf(leaf.view); + if (element == null) { + return; + } - const previousStatus = this.lastStatuses.get(filePath); - const currentStatus = - this.client.getDocumentSyncingStatus(filePath); - if (previousStatus === currentStatus) { - return; - } - this.lastStatuses.set(filePath, currentStatus); + const previousStatus = this.lastStatuses.get(filePath); + const currentStatus = + this.client.getDocumentSyncingStatus(filePath); + if (previousStatus === currentStatus) { + return; + } + this.lastStatuses.set(filePath, currentStatus); - if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) { - element.remove(); - return; - } + if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) { + element.remove(); + return; + } - if (currentStatus == DocumentSyncStatus.SYNCING) { - element.classList.add("loading"); - } else { - element.classList.remove("loading"); - } + if (currentStatus == DocumentSyncStatus.SYNCING) { + element.classList.add("loading"); + } else { + element.classList.remove("loading"); + } - const iconContainer = element.querySelector(".icon"); - if (iconContainer != null) { - setIcon( - iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - currentStatus == DocumentSyncStatus.SYNCING - ? "loader" - : "circle-check" - ); - } - } - }); - } + const iconContainer = element.querySelector(".icon"); + if (iconContainer != null) { + setIcon( + iconContainer as HTMLElement, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + currentStatus == DocumentSyncStatus.SYNCING + ? "loader" + : "circle-check" + ); + } + } + }); + } - private getElementFromLeaf(fileView: FileView): Element | undefined { - const parent = fileView.contentEl.querySelector(".cm-editor"); - if (parent == null) { - return; - } + private getElementFromLeaf(fileView: FileView): Element | undefined { + const parent = fileView.contentEl.querySelector(".cm-editor"); + if (parent == null) { + return; + } - return ( - parent.querySelector(".vault-link-sync-status") ?? - parent.createDiv( - { - cls: "vault-link-sync-status" - }, - (el) => { - el.createSpan({ text: "VaultLink sync state" }); - el.createDiv({ - cls: "icon" - }); - el.onclick = async (): Promise => - this.plugin.activateView(HistoryView.TYPE); - } - ) - ); - } + return ( + parent.querySelector(".vault-link-sync-status") ?? + parent.createDiv( + { + cls: "vault-link-sync-status" + }, + (el) => { + el.createSpan({ text: "VaultLink sync state" }); + el.createDiv({ + cls: "icon" + }); + el.onclick = async (): Promise => + this.plugin.activateView(HistoryView.TYPE); + } + ) + ); + } } diff --git a/frontend/obsidian-plugin/src/views/history/history-view.scss b/frontend/obsidian-plugin/src/views/history/history-view.scss index 6033fd2b..4e8b2a96 100644 --- a/frontend/obsidian-plugin/src/views/history/history-view.scss +++ b/frontend/obsidian-plugin/src/views/history/history-view.scss @@ -1,61 +1,61 @@ .history-card { - padding: var(--size-4-4); - margin: var(--size-4-2); - background-color: var(--color-base-00); - border-radius: var(--radius-l); - container-type: inline-size; + padding: var(--size-4-4); + margin: var(--size-4-2); + background-color: var(--color-base-00); + border-radius: var(--radius-l); + container-type: inline-size; + word-break: break-word; - &.clickable { - cursor: pointer; - } + &.clickable { + cursor: pointer; + } - &.success { - background-color: rgba(var(--color-green-rgb), 0.2); - } + &.success { + background-color: rgba(var(--color-green-rgb), 0.2); + } - &.error { - background-color: rgba(var(--color-red-rgb), 0.2); - } + &.error { + background-color: rgba(var(--color-red-rgb), 0.2); + } - &.skipped { - background-color: rgba(var(--color-green-rgb), 0.08); - } + &.skipped { + background-color: rgba(var(--color-green-rgb), 0.08); + } - .history-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--size-4-2); - gap: var(--size-4-2); + .history-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--size-4-2); + gap: var(--size-4-2); - @container (max-width: 300px) { - flex-direction: column; - align-items: flex-start; - } + @container (max-width: 300px) { + flex-direction: column; + align-items: flex-start; + } - .history-card-title { - font: var(--font-monospace); - display: flex; - align-items: center; - gap: var(--size-4-2); - word-break: break-all; - margin: 0; + .history-card-title { + font: var(--font-monospace); + display: flex; + align-items: center; + gap: var(--size-4-2); + margin: 0; - > span { - margin-bottom: var(--size-4-1); - } - } + > span { + margin-bottom: var(--size-4-1); + } + } - .history-card-timestamp { - font-size: var(--font-ui-small); - font-style: italic; - color: var(--italic-color); - } - } + .history-card-timestamp { + font-size: var(--font-ui-small); + font-style: italic; + color: var(--italic-color); + } + } - .history-card-message { - font-size: var(--font-ui-medium); - color: var(--color-base-70); - margin: 0; - } + .history-card-message { + font-size: var(--font-ui-medium); + color: var(--color-base-70); + margin: 0; + } } diff --git a/frontend/obsidian-plugin/src/views/history/history-view.ts b/frontend/obsidian-plugin/src/views/history/history-view.ts index 631fde72..65049f77 100644 --- a/frontend/obsidian-plugin/src/views/history/history-view.ts +++ b/frontend/obsidian-plugin/src/views/history/history-view.ts @@ -7,228 +7,234 @@ import type { HistoryEntry, SyncClient } from "sync-client"; import { SyncType } from "sync-client"; export class HistoryView extends ItemView { - public static readonly TYPE = "history-view"; - public static readonly ICON = "square-stack"; - private timer: NodeJS.Timeout | null = null; + public static readonly TYPE = "history-view"; + public static readonly ICON = "square-stack"; + private timer: NodeJS.Timeout | null = null; - private historyContainer: HTMLElement | undefined; - private readonly historyEntryToElement = new Map< - HistoryEntry, - HTMLElement - >(); + private historyContainer: HTMLElement | undefined; + private readonly historyEntryToElement = new Map< + HistoryEntry, + HTMLElement + >(); - public constructor( - private readonly client: SyncClient, - leaf: WorkspaceLeaf - ) { - super(leaf); - this.icon = HistoryView.ICON; + public constructor( + private readonly client: SyncClient, + leaf: WorkspaceLeaf + ) { + super(leaf); + this.icon = HistoryView.ICON; - this.client.addSyncHistoryUpdateListener(async () => - this.updateView().catch((error: unknown) => { - this.client.logger.error( - `Failed to update history view: ${error}` - ); - }) - ); - } + this.client.onSyncHistoryUpdated.add(async () => + this.updateView().catch((error: unknown) => { + this.client.logger.error( + `Failed to update history view: ${error}` + ); + }) + ); + } - private static getSyncTypeIcon(type: SyncType | undefined): IconName { - switch (type) { - case SyncType.CREATE: - return "file-plus"; - case SyncType.DELETE: - return "trash-2"; - case SyncType.UPDATE: - return "file-pen-line"; - case SyncType.MOVE: - return "move-right"; - case SyncType.SKIPPED: - return "circle-slash"; - case undefined: - default: - return ""; - } - } + private static getSyncTypeIcon(type: SyncType | undefined): IconName { + switch (type) { + case SyncType.CREATE: + return "file-plus"; + case SyncType.DELETE: + return "trash-2"; + case SyncType.UPDATE: + return "file-pen-line"; + case SyncType.MOVE: + return "move-right"; + case SyncType.SKIPPED: + return "circle-slash"; + case undefined: + default: + return ""; + } + } - private static renderSyncItemTitle( - element: HTMLElement, - entry: HistoryEntry - ): void { - const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type); - if (syncTypeIcon) { - setIcon(element.createDiv(), syncTypeIcon); - } + private static renderSyncItemTitle( + element: HTMLElement, + entry: HistoryEntry + ): void { + const syncTypeIcon = HistoryView.getSyncTypeIcon(entry.details.type); + if (syncTypeIcon) { + setIcon(element.createDiv(), syncTypeIcon); + } - let fileName = entry.details.relativePath.split("/").pop() ?? ""; - fileName = fileName.replace(/\.md$/, ""); + let fileName = entry.details.relativePath.split("/").pop() ?? ""; + fileName = fileName.replace(/\.md$/, ""); - element.createEl("span", { - text: - entry.details.type === SyncType.SKIPPED - ? `Skipped: ${fileName}` - : fileName - }); - } + element.createEl("span", { + text: + entry.details.type === SyncType.SKIPPED + ? `Skipped: ${fileName}` + : fileName + }); + } - private static updateTimeSince( - element: HTMLElement, - entry: HistoryEntry - ): void { - const timestampElement = element.querySelector( - ".history-card-timestamp" - ); + private static updateTimeSince( + element: HTMLElement, + entry: HistoryEntry + ): void { + const timestampElement = element.querySelector( + ".history-card-timestamp" + ); - if (timestampElement != null) { - timestampElement.textContent = - HistoryView.getTimestampAndAuthor(entry); - } - } + if (timestampElement != null) { + timestampElement.textContent = + HistoryView.getTimestampAndAuthor(entry); + } + } - private static getTimestampAndAuthor(entry: HistoryEntry): string { - let content = intlFormatDistance(entry.timestamp, new Date()); - if ("author" in entry && entry.author !== undefined) { - content += ` by ${entry.author}`; - } - return content; - } + private static getTimestampAndAuthor(entry: HistoryEntry): string { + let content = intlFormatDistance(entry.timestamp, new Date()); + if ("author" in entry && entry.author !== undefined) { + content += ` by ${entry.author}`; + } + return content; + } - public getViewType(): string { - return HistoryView.TYPE; - } + public getViewType(): string { + return HistoryView.TYPE; + } - public getDisplayText(): string { - return "VaultLink history"; - } + public getDisplayText(): string { + return "VaultLink history"; + } - public async onOpen(): Promise { - const container = this.containerEl.children[1]; - container.createEl("h4", { text: "VaultLink history" }); + public async onOpen(): Promise { + const container = this.containerEl.children[1]; + container.createEl("h4", { text: "VaultLink history" }); - this.historyContainer = container.createDiv({ cls: "logs-container" }); + this.historyContainer = container.createDiv({ cls: "logs-container" }); - await this.updateView(); - this.timer = setInterval( - () => - void this.updateView().catch((error: unknown) => { - this.client.logger.error( - `Failed to update history view: ${error}` - ); - }), - 1000 - ); - } + await this.updateView(); + this.clearTimer(); + this.timer = setInterval( + () => + void this.updateView().catch((error: unknown) => { + this.client.logger.error( + `Failed to update history view: ${error}` + ); + }), + 1000 + ); + } - public async onClose(): Promise { - if (this.timer) { - clearInterval(this.timer); - } - } + public async onClose(): Promise { + this.clearTimer(); + } - private async updateView(): Promise { - const container = this.historyContainer; - if (container === undefined) { - return; - } + private clearTimer(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } - // entries are newest first, but we prepend new ones - const entries = this.client.getHistoryEntries().toReversed(); + private async updateView(): Promise { + const container = this.historyContainer; + if (container === undefined) { + return; + } - if (this.historyEntryToElement.size === 0 && entries.length > 0) { - // Clear the "No update has happened yet" message - container.empty(); - } + // entries are newest first, but we prepend new ones + const entries = this.client.getHistoryEntries().toReversed(); - entries.forEach((entry) => { - const element = this.historyEntryToElement.get(entry); - if (element !== undefined) { - HistoryView.updateTimeSince(element, entry); - return; - } + if (this.historyEntryToElement.size === 0 && entries.length > 0) { + // Clear the "No update has happened yet" message + container.empty(); + } - const newElement = this.createHistoryCard(container, entry); - container.prepend(newElement); - this.historyEntryToElement.set(entry, newElement); - }); + entries.forEach((entry) => { + const element = this.historyEntryToElement.get(entry); + if (element !== undefined) { + HistoryView.updateTimeSince(element, entry); + return; + } - const newEntries = new Set(entries); - for (const [entry, element] of this.historyEntryToElement) { - if (!newEntries.has(entry)) { - element.remove(); - this.historyEntryToElement.delete(entry); - } - } + const newElement = this.createHistoryCard(container, entry); + container.prepend(newElement); + this.historyEntryToElement.set(entry, newElement); + }); - if (entries.length === 0) { - container.empty(); - container.createEl("p", { - text: "No update has happened yet." - }); - } - } + const newEntries = new Set(entries); + for (const [entry, element] of this.historyEntryToElement) { + if (!newEntries.has(entry)) { + element.remove(); + this.historyEntryToElement.delete(entry); + } + } - private createHistoryCard( - container: HTMLElement, - entry: HistoryEntry - ): HTMLElement { - return container.createDiv( - { - cls: ["history-card", entry.status.toLocaleLowerCase()] - }, - (card) => { - if ( - this.app.vault.getFileByPath(entry.details.relativePath) != - null - ) { - card.addEventListener("click", () => { - this.app.workspace - .openLinkText( - entry.details.relativePath, - entry.details.relativePath, - false - ) - .catch((error: unknown) => { - this.client.logger.error( - `Failed to open link for ${entry.details.relativePath}: ${error}` - ); - }); - }); + if (entries.length === 0) { + container.empty(); + container.createEl("p", { + text: "No update has happened yet." + }); + } + } - card.addClass("clickable"); - } + private createHistoryCard( + container: HTMLElement, + entry: HistoryEntry + ): HTMLElement { + return container.createDiv( + { + cls: ["history-card", entry.status.toLocaleLowerCase()] + }, + (card) => { + if ( + this.app.vault.getFileByPath(entry.details.relativePath) != + null + ) { + card.addEventListener("click", () => { + this.app.workspace + .openLinkText( + entry.details.relativePath, + entry.details.relativePath, + false + ) + .catch((error: unknown) => { + this.client.logger.error( + `Failed to open link for ${entry.details.relativePath}: ${error}` + ); + }); + }); - card.createDiv( - { - cls: "history-card-header" - }, - (header) => { - header.createEl( - "h5", - { - cls: "history-card-title" - }, - (title) => { - HistoryView.renderSyncItemTitle(title, entry); - } - ); + card.addClass("clickable"); + } - header.createSpan({ - text: HistoryView.getTimestampAndAuthor(entry), - cls: "history-card-timestamp" - }); - } - ); + card.createDiv( + { + cls: "history-card-header" + }, + (header) => { + header.createEl( + "h5", + { + cls: "history-card-title" + }, + (title) => { + HistoryView.renderSyncItemTitle(title, entry); + } + ); - const body = - entry.details.type === SyncType.MOVE - ? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'` - : `${entry.message}.`; + header.createSpan({ + text: HistoryView.getTimestampAndAuthor(entry), + cls: "history-card-timestamp" + }); + } + ); - card.createEl("p", { - text: body, - cls: "history-card-message" - }); - } - ); - } + const body = + entry.details.type === SyncType.MOVE + ? `${entry.message}. Moved from '${entry.details.movedFrom}' to '${entry.details.relativePath}'` + : `${entry.message}.`; + + card.createEl("p", { + text: body, + cls: "history-card-message" + }); + } + ); + } } diff --git a/frontend/obsidian-plugin/src/views/logs/logs-view.scss b/frontend/obsidian-plugin/src/views/logs/logs-view.scss index 82ed1037..47b4cb29 100644 --- a/frontend/obsidian-plugin/src/views/logs/logs-view.scss +++ b/frontend/obsidian-plugin/src/views/logs/logs-view.scss @@ -1,60 +1,74 @@ .logs-view { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; - .verbosity-selector { - display: flex; - align-items: center; - justify-content: space-between; - font-weight: normal; - gap: var(--size-4-2); - margin: var(--size-4-4) var(--size-4-2); + .verbosity-selector { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: normal; + gap: var(--size-4-2); + margin: var(--size-4-4) var(--size-4-2); - h4 { - margin: 0; - } + h4 { + margin: 0; + } - select { - cursor: pointer; - } - } + .logs-controls { + display: flex; + align-items: center; + gap: var(--size-4-2); - .logs-container { - max-width: 100%; - overflow-y: auto; + button { + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-2) var(--size-4-2); + cursor: pointer; + } - .log-message { - font: var(--font-monospace); - margin-bottom: var(--size-2-1); - overflow-wrap: break-word; - white-space: pre-wrap; - user-select: all; + select { + cursor: pointer; + } + } + } - .timestamp { - padding: var(--size-2-1) var(--size-4-1); - border-radius: var(--radius-s); - background-color: var(--color-base-30); - font-size: var(--font-ui-small); - font-family: var(--font-monospace); - font-weight: var(--bold-weight); - margin-right: var(--size-4-1); - } + .logs-container { + max-width: 100%; + overflow-y: auto; - &.DEBUG { - color: var(--color-base-50); - } + .log-message { + font: var(--font-monospace); + margin-bottom: var(--size-2-1); + overflow-wrap: break-word; + white-space: pre-wrap; + user-select: all; - &.INFO { - color: var(--color-base-100); - } + .timestamp { + padding: var(--size-2-1) var(--size-4-1); + border-radius: var(--radius-s); + background-color: var(--color-base-30); + font-size: var(--font-ui-small); + font-family: var(--font-monospace); + font-weight: var(--bold-weight); + margin-right: var(--size-4-1); + } - &.WARNING { - color: rgb(var(--color-yellow-rgb)); - } + &.DEBUG { + color: var(--color-base-50); + } - &.ERROR { - color: rgb(var(--color-red-rgb)); - } - } - } + &.INFO { + color: var(--color-base-100); + } + + &.WARNING { + color: rgb(var(--color-yellow-rgb)); + } + + &.ERROR { + color: rgb(var(--color-red-rgb)); + } + } + } } diff --git a/frontend/obsidian-plugin/src/views/logs/logs-view.ts b/frontend/obsidian-plugin/src/views/logs/logs-view.ts index 19cf4701..83c41b66 100644 --- a/frontend/obsidian-plugin/src/views/logs/logs-view.ts +++ b/frontend/obsidian-plugin/src/views/logs/logs-view.ts @@ -1,152 +1,194 @@ import "./logs-view.scss"; import type { WorkspaceLeaf } from "obsidian"; -import { ItemView } from "obsidian"; +import { ItemView, Notice, setIcon } from "obsidian"; import type { LogLine } from "sync-client"; import { LogLevel, type SyncClient } from "sync-client"; export class LogsView extends ItemView { - public static readonly TYPE = "logs-view"; - public static readonly ICON = "logs"; + public static readonly TYPE = "logs-view"; + public static readonly ICON = "logs"; - private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300; + private static readonly MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX = 300; - private logsContainer: HTMLElement | undefined; - private readonly logLineToElement = new Map(); - private minLogLevel: LogLevel = LogLevel.INFO; + private logsContainer: HTMLElement | undefined; + private readonly logLineToElement = new Map(); + private minLogLevel: LogLevel = LogLevel.INFO; - public constructor( - private readonly client: SyncClient, - leaf: WorkspaceLeaf - ) { - super(leaf); - this.icon = LogsView.ICON; - this.client.logger.addOnMessageListener(() => { - this.updateView(); - }); - } + public constructor( + private readonly client: SyncClient, + leaf: WorkspaceLeaf + ) { + super(leaf); + this.icon = LogsView.ICON; + this.client.logger.onLogEmitted.add(() => { + this.updateView(); + }); + } - private static createLogLineElement( - container: HTMLElement, - logLine: LogLine - ): HTMLElement { - return container.createDiv( - { - cls: ["log-message", logLine.level] - }, - (messageContainer) => { - messageContainer.createEl("span", { - text: LogsView.formatTimestamp(logLine.timestamp), - cls: "timestamp" - }); - messageContainer.createEl("span", { - text: logLine.message - }); - } - ); - } + private static createLogLineElement( + container: HTMLElement, + logLine: LogLine + ): HTMLElement { + return container.createDiv( + { + cls: ["log-message", logLine.level] + }, + (messageContainer) => { + messageContainer.createEl("span", { + text: LogsView.formatTimestamp(logLine.timestamp), + cls: "timestamp" + }); + messageContainer.createEl("span", { + text: logLine.message + }); + } + ); + } - private static formatTimestamp(timestamp: Date): string { - return timestamp.toTimeString().split(" ")[0]; - } + private static formatTimestamp(timestamp: Date): string { + return timestamp.toTimeString().split(" ")[0]; + } - public getViewType(): string { - return LogsView.TYPE; - } + public getViewType(): string { + return LogsView.TYPE; + } - public getDisplayText(): string { - return "VaultLink logs"; - } + public getDisplayText(): string { + return "VaultLink logs"; + } - public async onOpen(): Promise { - const container = this.containerEl.children[1]; - container.addClass("logs-view"); + public async onOpen(): Promise { + const container = this.containerEl.children[1]; + container.addClass("logs-view"); - const logLevels = [ - { label: "Debug", value: LogLevel.DEBUG }, - { label: "Info", value: LogLevel.INFO }, - { label: "Warn", value: LogLevel.WARNING }, - { label: "Error", value: LogLevel.ERROR } - ]; + const logLevels = [ + { label: "Debug", value: LogLevel.DEBUG }, + { label: "Info", value: LogLevel.INFO }, + { label: "Warn", value: LogLevel.WARNING }, + { label: "Error", value: LogLevel.ERROR } + ]; - container.createDiv( - { - cls: "verbosity-selector" - }, - (verbositySection) => { - verbositySection.createEl("h4", { - text: "VaultLink logs" - }); + container.createDiv( + { + cls: "verbosity-selector" + }, + (verbositySection) => { + verbositySection.createEl("h4", { + text: "VaultLink logs" + }); - verbositySection.createEl("select", {}, (dropdown) => { - logLevels.forEach(({ label, value }) => - dropdown.createEl("option", { text: label, value }) - ); + const controls = verbositySection.createDiv({ + cls: "logs-controls" + }); - dropdown.value = this.minLogLevel; + const copyButton = controls.createEl("button", { + text: "Copy logs", + cls: "clickable-icon" + }); + setIcon(copyButton, "clipboard-copy"); + copyButton.addEventListener("click", () => { + this.copyLogsToClipboard(); + }); - dropdown.addEventListener("change", () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - this.minLogLevel = dropdown.value as LogLevel; + controls.createEl("select", {}, (dropdown) => { + logLevels.forEach(({ label, value }) => + dropdown.createEl("option", { text: label, value }) + ); - this.logsContainer?.empty(); - this.logLineToElement.clear(); - this.updateView(); - }); - }); - } - ); + dropdown.value = this.minLogLevel; - this.logsContainer = container.createDiv({ cls: "logs-container" }); + dropdown.addEventListener("change", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.minLogLevel = dropdown.value as LogLevel; - this.updateView(); - } + this.logsContainer?.empty(); + this.logLineToElement.clear(); + this.updateView(); + }); + }); + } + ); - private updateView(): void { - const container = this.logsContainer; - if (container === undefined) { - return; - } + this.logsContainer = container.createDiv({ cls: "logs-container" }); - const logs = this.client.logger.getMessages(this.minLogLevel); + this.updateView(); + } - if (this.logLineToElement.size === 0 && logs.length > 0) { - // Clear the "No logs available yet" message - container.empty(); - } + private copyLogsToClipboard(): void { + const logs = this.client.logger.getMessages(this.minLogLevel); - const shouldScroll = - container.scrollTop == 0 || - container.scrollHeight - - container.clientHeight - - container.scrollTop < - LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX; + if (logs.length === 0) { + new Notice("No logs to copy"); + return; + } - logs.forEach((message) => { - if (this.logLineToElement.has(message)) { - return; - } + const formattedLogs = logs + .map((logLine) => { + const timestamp = logLine.timestamp.toLocaleString(); + const level = logLine.level.toUpperCase(); + return `[${timestamp}] ${level}: ${logLine.message}`; + }) + .join("\n"); - const element = LogsView.createLogLineElement(container, message); + navigator.clipboard + .writeText(formattedLogs) + .then(() => { + new Notice(`Copied ${logs.length} log entries to clipboard`); + }) + .catch((error: unknown) => { + this.client.logger.error( + `Failed to copy logs to clipboard: ${error}` + ); + new Notice("Failed to copy logs to clipboard"); + }); + } - this.logLineToElement.set(message, element); - }); + private updateView(): void { + const container = this.logsContainer; + if (container === undefined) { + return; + } - const newLines = new Set(logs); - for (const [logLine, element] of this.logLineToElement) { - if (!newLines.has(logLine)) { - element.remove(); - this.logLineToElement.delete(logLine); - } - } + const logs = this.client.logger.getMessages(this.minLogLevel); - if (logs.length === 0) { - container.empty(); - container.createEl("p", { - text: "No logs available yet." - }); - } else if (shouldScroll) { - container.scrollTop = container.scrollHeight; - } - } + if (this.logLineToElement.size === 0 && logs.length > 0) { + // Clear the "No logs available yet" message + container.empty(); + } + + const shouldScroll = + container.scrollTop == 0 || + container.scrollHeight - + container.clientHeight - + container.scrollTop < + LogsView.MAX_OFFSET_FROM_BOTTOM_WITH_AUTO_SCROLL_PX; + + logs.forEach((message) => { + if (this.logLineToElement.has(message)) { + return; + } + + const element = LogsView.createLogLineElement(container, message); + + this.logLineToElement.set(message, element); + }); + + const newLines = new Set(logs); + for (const [logLine, element] of this.logLineToElement) { + if (!newLines.has(logLine)) { + element.remove(); + this.logLineToElement.delete(logLine); + } + } + + if (logs.length === 0) { + container.empty(); + container.createEl("p", { + text: "No logs available yet." + }); + } else if (shouldScroll) { + container.scrollTop = container.scrollHeight; + } + } } diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.scss b/frontend/obsidian-plugin/src/views/settings/settings-tab.scss index dcc3e806..579c5b8c 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.scss +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.scss @@ -1,57 +1,134 @@ @mixin number-card { - padding: var(--size-2-1) var(--size-4-1); - border-radius: var(--radius-s); - background-color: var(--color-base-30); - font-size: var(--font-ui-small); + padding: var(--size-2-1) var(--size-4-1); + border-radius: var(--radius-s); + background-color: var(--color-base-30); + font-size: var(--font-ui-small); - &.good { - background-color: rgba(var(--color-green-rgb), 0.35); - } + &.good { + background-color: rgba(var(--color-green-rgb), 0.35); + } - &.bad { - background-color: rgba(var(--color-red-rgb), 0.35); - } + &.bad { + background-color: rgba(var(--color-red-rgb), 0.35); + } } -.vault-link-settings { - h2 { - display: flex; - align-items: center; - font-size: var(--h2-size); +.vault-link-settings-container { + position: relative; - .version { - @include number-card; - margin: var(--size-2-2) 0 0 var(--size-4-2); - background-color: var(--color-base-30); - color: var(--color-base-70); - font-size: var(--font-ui-smaller); - } - } + .vault-link-settings { + h2 { + display: flex; + align-items: center; + font-size: var(--h2-size); - .button-container { - display: flex; - gap: var(--size-4-2); - } + .version { + @include number-card; + margin: var(--size-2-2) 0 0 var(--size-4-2); + background-color: var(--color-base-30); + color: var(--color-base-70); + font-size: var(--font-ui-smaller); + } + } - h3 { - font-size: var(--font-ui-large); - margin-top: var(--heading-spacing); - } + .button-container { + display: flex; + gap: var(--size-4-2); + } - button, - input[type="range"], - .checkbox-container, - .slider::-webkit-slider-thumb { - cursor: pointer; - } + h3 { + font-size: var(--font-ui-large); + margin-top: var(--heading-spacing); + } - input[type="text"], - textarea { - width: 250px; - } + button, + input[type="range"], + .checkbox-container, + .slider::-webkit-slider-thumb { + cursor: pointer; + } - textarea { - resize: none; - height: 75px; - } + input[type="text"], + textarea { + width: 250px; + } + + textarea { + resize: none; + height: 75px; + } + + .applying-changes-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translateY(-50%) translateX(-50%); + z-index: 10; + backdrop-filter: blur(10px); + + .spinner-container { + background-color: rgba(var(--background-primary), 0.5); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + padding: var(--size-4-8); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--size-4-3); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + min-width: 200px; + } + + .spinner { + width: 48px; + height: 48px; + border: 4px solid var(--background-modifier-border); + border-top-color: var(--interactive-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + .spinner-text { + color: var(--text-normal); + font-size: var(--font-ui-medium); + font-weight: 500; + } + + .spinner-warning { + color: var(--text-muted); + font-size: var(--font-ui-small); + text-align: center; + margin-top: var(--size-2-2); + } + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } + + &.applying-changes { + .setting-item-control { + pointer-events: none; + opacity: 0.5; + } + + button:not(.applying-changes-overlay button) { + pointer-events: none; + opacity: 0.5; + } + + input, + textarea, + select { + pointer-events: none; + opacity: 0.5; + } + } + } } diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts index 2d129edc..5a5823c2 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -9,376 +9,506 @@ import { LogsView } from "../logs/logs-view"; import type { StatusDescription } from "../status-description/status-description"; export class SyncSettingsTab extends PluginSettingTab { - private editedServerUri: string; - private editedToken: string; - private editedVaultName: string; + private editedServerUri: string; + private editedToken: string; + private editedVaultName: string; - private readonly plugin: VaultLinkPlugin; - private readonly syncClient: SyncClient; - private readonly statusDescription: StatusDescription; - private statusDescriptionSubscription: (() => unknown) | undefined; + private _isApplyingChanges = false; + private syncEnabledOverride: boolean | undefined = undefined; - public constructor({ - app, - plugin, - syncClient, - statusDescription - }: { - app: App; - plugin: VaultLinkPlugin; - syncClient: SyncClient; - statusDescription: StatusDescription; - }) { - super(app, plugin); - this.plugin = plugin; - this.syncClient = syncClient; - this.statusDescription = statusDescription; + private readonly plugin: VaultLinkPlugin; + private readonly syncClient: SyncClient; + private readonly statusDescription: StatusDescription; + private statusDescriptionSubscription: (() => unknown) | undefined; - this.editedServerUri = this.syncClient.getSettings().remoteUri; - this.editedToken = this.syncClient.getSettings().token; - this.editedVaultName = this.syncClient.getSettings().vaultName; + public constructor({ + app, + plugin, + syncClient, + statusDescription + }: { + app: App; + plugin: VaultLinkPlugin; + syncClient: SyncClient; + statusDescription: StatusDescription; + }) { + super(app, plugin); + this.plugin = plugin; + this.syncClient = syncClient; + this.statusDescription = statusDescription; - this.syncClient.addOnSettingsChangeListener( - (newSettings, oldSettings) => { - let hasChanged = false; + this.editedServerUri = this.syncClient.getSettings().remoteUri; + this.editedToken = this.syncClient.getSettings().token; + this.editedVaultName = this.syncClient.getSettings().vaultName; - if (newSettings.remoteUri !== oldSettings.remoteUri) { - this.editedServerUri = newSettings.remoteUri; - hasChanged = true; - } + this.syncClient.onSettingsChanged.add((newSettings, oldSettings) => { + let hasChanged = false; - if (newSettings.token !== oldSettings.token) { - this.editedToken = newSettings.token; - hasChanged = true; - } + if (newSettings.remoteUri !== oldSettings.remoteUri) { + this.editedServerUri = newSettings.remoteUri; + hasChanged = true; + } - if (newSettings.vaultName !== oldSettings.vaultName) { - this.editedVaultName = newSettings.vaultName; - hasChanged = true; - } + if (newSettings.token !== oldSettings.token) { + this.editedToken = newSettings.token; + hasChanged = true; + } - if (hasChanged) { - this.display(); - } - } - ); - } + if (newSettings.vaultName !== oldSettings.vaultName) { + this.editedVaultName = newSettings.vaultName; + hasChanged = true; + } - public display(): void { - const { containerEl } = this; - containerEl.empty(); - containerEl.addClass("vault-link-settings"); + if (hasChanged) { + this.display(); + } + }); + } - this.renderSettingsHeader(containerEl); - this.renderConnectionSettings(containerEl); - this.renderSyncSettings(containerEl); - } + private get isApplyingChanges(): boolean { + return this._isApplyingChanges; + } - public hide(): void { - super.hide(); - this.setStatusDescriptionSubscription(); - } + private set isApplyingChanges(value: boolean) { + this._isApplyingChanges = value; + this.display(); + } - private renderSettingsHeader(containerEl: HTMLElement): void { - containerEl.createEl("h2", { text: "VaultLink" }).createSpan({ - text: this.plugin.manifest.version, - cls: "version" - }); + public display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass("vault-link-settings"); + containerEl.parentElement?.addClass("vault-link-settings-container"); - containerEl.createDiv( - { - cls: "description" - }, - (descriptionContainer) => { - this.setStatusDescriptionSubscription( - this.statusDescription.renderStatusDescription.bind( - this.statusDescription, - descriptionContainer - ) - ); - } - ); + if (this.isApplyingChanges) { + containerEl.addClass("applying-changes"); + } else { + containerEl.removeClass("applying-changes"); + } - containerEl.createDiv( - { - cls: "button-container" - }, - (buttonContainer) => { - buttonContainer.createEl( - "button", - { - text: "Show history" - }, - (button) => - (button.onclick = async (): Promise => { - this.plugin.closeSettings(); - await this.plugin.activateView(HistoryView.TYPE); - }) - ); + this.renderApplyingChanges(containerEl); + this.renderSettingsHeader(containerEl); + this.renderConnectionSettings(containerEl); + this.renderSyncSettings(containerEl); + this.renderMiscSettings(containerEl); + } - buttonContainer.createEl( - "button", - { - text: "Show logs" - }, - (button) => - (button.onclick = async (): Promise => { - this.plugin.closeSettings(); - await this.plugin.activateView(LogsView.TYPE); - }) - ); - } - ); - } + public hide(): void { + super.hide(); + this.setStatusDescriptionSubscription(); + } - private renderConnectionSettings(containerEl: HTMLElement): void { - containerEl.createEl("h3", { text: "Connection" }); + private renderApplyingChanges(containerEl: HTMLElement): void { + if (this.isApplyingChanges) { + const overlay = containerEl.createDiv({ + cls: "applying-changes-overlay" + }); - const [title, updateTitle] = this.unsavedAwareSettingName( - "Server address", - "remoteUri" - ); - new Setting(containerEl) - .setName(title) - .setDesc( - "Your VaultLink server's URL including the protocol and full path." - ) - .setTooltip("This is the URL of the server you want to sync with.") - .addText((text) => - text - .setPlaceholder("https://example.com:3000") - .setValue(this.editedServerUri.toLowerCase().trim()) - .onChange((value) => { - this.editedServerUri = value.toLowerCase().trim(); - updateTitle(value.toLowerCase().trim()); - }) - ); + const spinnerContainer = overlay.createDiv({ + cls: "spinner-container" + }); - const [tokenTitle, updateTokenTitle] = this.unsavedAwareSettingName( - "Access token", - "token" - ); - new Setting(containerEl) - .setName(tokenTitle) - .setClass("sync-settings-access-token") - .setDesc( - "Set the access token for the server that you can get from the server" - ) - .setTooltip("todo, links to dcocs") - .addTextArea((text) => - text - .setPlaceholder("ey...") - .setValue(this.editedToken.trim()) - .onChange((value) => { - this.editedToken = value.trim(); - updateTokenTitle(value.trim()); - }) - ); + spinnerContainer.createDiv({ + cls: "spinner" + }); - const [vaultNameTitle, updateVaultNameTitle] = - this.unsavedAwareSettingName("Vault name", "vaultName"); - new Setting(containerEl) - .setName(vaultNameTitle) - .setDesc( - "Set the name of the remote vault that you want to sync with" - ) - .setTooltip("todo, links to dcocs") - .addText((text) => - text - .setPlaceholder("My Obsidian Vault") - .setValue(this.editedVaultName.toLowerCase().trim()) - .onChange((value) => { - this.editedVaultName = value.toLowerCase().trim(); - updateVaultNameTitle(value.toLowerCase().trim()); - }) - ); + spinnerContainer.createDiv({ + text: "Applying changes...", + cls: "spinner-text" + }); - new Setting(containerEl) - .addButton((button) => - button.setButtonText("Apply").onClick(async () => { - if (this.areThereUnsavedChanges()) { - await this.syncClient.setSettings({ - vaultName: this.editedVaultName, - remoteUri: this.editedServerUri, - token: this.editedToken - }); - new Notice( - "The changes have been applied successfully!" - ); - await this.statusDescription.updateConnectionState(); - } else { - new Notice("No changes to apply"); - } - }) - ) - .addButton((button) => - button.setButtonText("Test connection").onClick(async () => { - if (this.areThereUnsavedChanges()) { - new Notice( - "There are unsaved changes, testing with the currently saved settings" - ); - } + spinnerContainer.createDiv({ + text: "You can exit, but changes won't be saved", + cls: "spinner-warning" + }); + } + } - new Notice( - (await this.syncClient.checkConnection()).serverMessage - ); - await this.statusDescription.updateConnectionState(); - }) - ); - } + private renderSettingsHeader(containerEl: HTMLElement): void { + containerEl.createEl("h2", { text: "VaultLink" }).createSpan({ + text: this.plugin.manifest.version, + cls: "version" + }); - private areThereUnsavedChanges(): boolean { - return ( - this.editedServerUri !== this.syncClient.getSettings().remoteUri || - this.editedToken !== this.syncClient.getSettings().token || - this.editedVaultName !== this.syncClient.getSettings().vaultName - ); - } + containerEl.createDiv( + { + cls: "description" + }, + (descriptionContainer) => { + this.setStatusDescriptionSubscription( + this.statusDescription.renderStatusDescription.bind( + this.statusDescription, + descriptionContainer + ) + ); + } + ); - private renderSyncSettings(containerEl: HTMLElement): void { - containerEl.createEl("h3", { text: "Sync" }); + containerEl.createDiv( + { + cls: "button-container" + }, + (buttonContainer) => { + buttonContainer.createEl( + "button", + { + text: "Show history" + }, + (button) => + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(HistoryView.TYPE); + }) + ); - new Setting(containerEl) - .setName("Enable sync") - .setDesc( - "Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server." - ) - .setTooltip( - "Enable pulling and pushing changes to the remote server." - ) - .addToggle((toggle) => - toggle - .setValue(this.syncClient.getSettings().isSyncEnabled) - .onChange(async (value) => - this.syncClient.setSetting("isSyncEnabled", value) - ) - ); + buttonContainer.createEl( + "button", + { + text: "Show logs" + }, + (button) => + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(LogsView.TYPE); + }) + ); + } + ); + } - new Setting(containerEl) - .setName("Ignore patterns") - .setDesc( - "Patterns to ignore when syncing. Each line is a separate glob pattern. Patterns are matched against the relative path of the file. For example, to ignore all files in a folder named 'ignore', enter 'ignore/*'. To ignore all files with the extension '.log', enter '*.log'." - ) - .addTextArea((text) => - text - .setValue( - this.syncClient.getSettings().ignorePatterns.join("\n") - ) - .setPlaceholder("Enter patterns to ignore, one per line") - .onChange(async (value) => { - const patterns = value - .split("\n") - .map((pattern) => pattern.trim()) - .filter((pattern) => pattern.length > 0); - return this.syncClient.setSetting( - "ignorePatterns", - patterns - ); - }) - ); + private renderConnectionSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Connection" }); - new Setting(containerEl) - .setName("Sync concurrency") - .setDesc( - "How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1." - ) - .addSlider((text) => - text - .setLimits(1, 16, 1) - .setDynamicTooltip() - .setInstant(false) - .setValue(this.syncClient.getSettings().syncConcurrency) - .onChange(async (value) => - this.syncClient.setSetting("syncConcurrency", value) - ) - ); + const [title, updateTitle] = this.unsavedAwareSettingName( + "Server address", + "remoteUri" + ); + new Setting(containerEl) + .setName(title) + .setDesc( + "Your VaultLink server's URL including the protocol and full path." + ) + .setTooltip("This is the URL of the server you want to sync with.") + .addText((text) => + text + .setPlaceholder("https://example.com:3000") + .setValue(this.editedServerUri.toLowerCase().trim()) + .onChange((value) => { + this.editedServerUri = value.toLowerCase().trim(); + updateTitle(value.toLowerCase().trim()); + }) + ); - new Setting(containerEl) - .setName("Maximum file size to be uploaded (MB)") - .setDesc( - "Set the maximum file size that can be uploaded to the server. Files larger than this size will be ignored." - ) - .addText((input) => - input - .setValue( - this.syncClient.getSettings().maxFileSizeMB.toString() - ) - .onChange(async (value) => { - if (value === "") { - return; - } - let parsedValue = Number.parseFloat(value); - if (Number.isNaN(parsedValue) || parsedValue < 0) { - parsedValue = - this.syncClient.getSettings().maxFileSizeMB; - } + const [tokenTitle, updateTokenTitle] = this.unsavedAwareSettingName( + "Access token", + "token" + ); + new Setting(containerEl) + .setName(tokenTitle) + .setClass("sync-settings-access-token") + .setDesc( + "Set the access token for the server that you can get from the server" + ) + .setTooltip("todo, links to dcocs") + .addTextArea((text) => + text + .setPlaceholder("ey...") + .setValue(this.editedToken.trim()) + .onChange((value) => { + this.editedToken = value.trim(); + updateTokenTitle(value.trim()); + }) + ); - if (value !== parsedValue.toString()) { - input.setValue(parsedValue.toString()); - } + const [vaultNameTitle, updateVaultNameTitle] = + this.unsavedAwareSettingName("Vault name", "vaultName"); + new Setting(containerEl) + .setName(vaultNameTitle) + .setDesc( + "Set the name of the remote vault that you want to sync with" + ) + .setTooltip("todo, links to dcocs") + .addText((text) => + text + .setPlaceholder("My Obsidian Vault") + .setValue(this.editedVaultName.toLowerCase().trim()) + .onChange((value) => { + this.editedVaultName = value.toLowerCase().trim(); + updateVaultNameTitle(value.toLowerCase().trim()); + }) + ); - return this.syncClient.setSetting( - "maxFileSizeMB", - parsedValue - ); - }) - ); + new Setting(containerEl).addButton((button) => + button + .setButtonText("Apply & test connection") + .setDisabled(this.isApplyingChanges) + .setTooltip( + this.isApplyingChanges + ? "Waiting for applying changes to finish..." + : "Apply the changes made to the connection settings and test the connection to the server." + ) + .onClick(() => { + // don't show loader within the button + void (async (): Promise => { + if (this.areThereUnsavedChanges()) { + new Notice("Applying changes to the server..."); - new Setting(containerEl) - .setName("Danger zone") - .setDesc( - "Delete the local metadata database while leaving the local and remote files intact." - ) - .addButton((button) => - button.setButtonText("Reset sync state").onClick(async () => { - await this.syncClient.reset(); - new Notice( - "Sync state has been reset, you will need to resync" - ); - }) - ); - } + this.isApplyingChanges = true; + try { + await this.syncClient.setSettings({ + vaultName: this.editedVaultName, + remoteUri: this.editedServerUri, + token: this.editedToken + }); + } finally { + this.isApplyingChanges = false; + } - private setStatusDescriptionSubscription( - newSubscription?: () => unknown - ): void { - if (this.statusDescriptionSubscription) { - this.statusDescription.removeStatusChangeListener( - this.statusDescriptionSubscription - ); - } - this.statusDescriptionSubscription = newSubscription; - if (this.statusDescriptionSubscription) { - this.statusDescriptionSubscription(); - this.statusDescription.addStatusChangeListener( - this.statusDescriptionSubscription - ); - } - } + new Notice("Checking connection to the server..."); + new Notice( + (await this.syncClient.checkConnection()) + .serverMessage + ); + await this.statusDescription.updateConnectionState(); + } else { + new Notice("No changes to apply"); + } + })(); + }) + ); + } - private unsavedAwareSettingName( - name: string, - settingName: keyof SyncSettings - ): [ - DocumentFragment, - (newValue: SyncSettings[keyof SyncSettings]) => unknown - ] { - const titleContainer = document.createDocumentFragment(); - const title = titleContainer.createEl("div", { - text: name, - cls: "setting-item-name" - }); + private areThereUnsavedChanges(): boolean { + return ( + this.editedServerUri !== this.syncClient.getSettings().remoteUri || + this.editedToken !== this.syncClient.getSettings().token || + this.editedVaultName !== this.syncClient.getSettings().vaultName + ); + } - const updateTitle = ( - currentValue: SyncSettings[keyof SyncSettings] - ): void => { - title.innerText = `${name}${ - currentValue !== this.syncClient.getSettings()[settingName] - ? " (unsaved)" - : "" - }`; - }; + private renderSyncSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Sync" }); - return [titleContainer, updateTitle]; - } + new Setting(containerEl) + .setName("Enable sync") + .setDesc( + "Enable pulling and pushing changes to the remote server. The first time it's enabled, or after the sync state has been reset, all local files will be pushed to the server." + ) + .setTooltip( + "Enable pulling and pushing changes to the remote server." + ) + .addToggle((toggle) => + toggle + .setValue( + this.syncEnabledOverride ?? + this.syncClient.getSettings().isSyncEnabled + ) + .setDisabled(this.isApplyingChanges) + .setTooltip( + this.isApplyingChanges + ? "Waiting for applying changes to finish..." + : "Enable or disable syncing." + ) + .onChange( + (value) => + void (async (): Promise => { + this.syncEnabledOverride = value; + this.isApplyingChanges = true; + try { + await this.syncClient.setSetting( + "isSyncEnabled", + value + ); + } finally { + this.syncEnabledOverride = undefined; + this.isApplyingChanges = false; + } + })() + ) + ); + + new Setting(containerEl) + .setName("Ignore patterns") + .setDesc( + "Patterns to ignore when syncing. Each line is a separate glob pattern. Patterns are matched against the relative path of the file. For example, to ignore all files in a folder named 'ignore', enter 'ignore/*'. To ignore all files with the extension '.log', enter '*.log'." + ) + .addTextArea((text) => + text + .setValue( + this.syncClient.getSettings().ignorePatterns.join("\n") + ) + .setPlaceholder("Enter patterns to ignore, one per line") + .onChange(async (value) => { + const patterns = value + .split("\n") + .map((pattern) => pattern.trim()) + .filter((pattern) => pattern.length > 0); + return this.syncClient.setSetting( + "ignorePatterns", + patterns + ); + }) + ); + + new Setting(containerEl) + .setName("Maximum file size to be uploaded (MB)") + .setDesc( + "Set the maximum file size that can be uploaded to the server. Files larger than this size will be ignored." + ) + .addText((input) => + input + .setValue( + this.syncClient.getSettings().maxFileSizeMB.toString() + ) + .onChange(async (value) => { + if (value === "") { + return; + } + let parsedValue = Number.parseFloat(value); + if (Number.isNaN(parsedValue) || parsedValue < 0) { + parsedValue = + this.syncClient.getSettings().maxFileSizeMB; + } + + if (value !== parsedValue.toString()) { + input.setValue(parsedValue.toString()); + } + + return this.syncClient.setSetting( + "maxFileSizeMB", + parsedValue + ); + }) + ); + + new Setting(containerEl) + .setName("Danger zone") + .setDesc( + "Delete the local metadata database while leaving the local and remote files intact." + ) + .addButton((button) => + button + .setDisabled(this.isApplyingChanges) + .setTooltip( + this.isApplyingChanges + ? "Waiting for applying changes to finish..." + : "Reset sync state" + ) + .setButtonText("Reset sync state") + .onClick( + () => + void (async (): Promise => { + this.isApplyingChanges = true; + try { + await this.syncClient.reset(); + } finally { + this.isApplyingChanges = false; + } + + new Notice( + "Sync state has been reset, you will need to resync" + ); + })() + ) + ); + } + + private renderMiscSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Other" }); + + new Setting(containerEl) + .setName("Enable telemetry") + .setDesc( + "Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties." + ) + .setTooltip( + "Allow sending anonymous usage data & error reports to help improve the plugin. The data collected is never shared with third parties." + ) + .addToggle((toggle) => + toggle + .setValue(this.syncClient.getSettings().enableTelemetry) + .onChange(async (value) => + this.syncClient.setSetting("enableTelemetry", value) + ) + ); + + containerEl.createEl("h3", { text: "Advanced" }); + + new Setting(containerEl) + .setName("Network retry interval (ms)") + .setDesc( + "The time to wait between retrying failed network requests, in milliseconds." + ) + .addText((input) => + input + .setValue( + this.syncClient + .getSettings() + .networkRetryIntervalMs.toString() + ) + .onChange(async (value) => { + if (value === "") { + return; + } + let parsedValue = Number.parseInt(value, 10); + if (Number.isNaN(parsedValue) || parsedValue < 0) { + parsedValue = + this.syncClient.getSettings() + .networkRetryIntervalMs; + } + + if (value !== parsedValue.toString()) { + input.setValue(parsedValue.toString()); + } + + return this.syncClient.setSetting( + "networkRetryIntervalMs", + parsedValue + ); + }) + ); + } + + private setStatusDescriptionSubscription( + newSubscription?: () => unknown + ): void { + if (this.statusDescriptionSubscription) { + this.statusDescription.removeStatusChangeListener( + this.statusDescriptionSubscription + ); + } + this.statusDescriptionSubscription = newSubscription; + if (this.statusDescriptionSubscription) { + this.statusDescriptionSubscription(); + this.statusDescription.addStatusChangeListener( + this.statusDescriptionSubscription + ); + } + } + + private unsavedAwareSettingName( + name: string, + settingName: keyof SyncSettings + ): [ + DocumentFragment, + (newValue: SyncSettings[keyof SyncSettings]) => unknown + ] { + const titleContainer = document.createDocumentFragment(); + const title = titleContainer.createEl("div", { + text: name, + cls: "setting-item-name" + }); + + const updateTitle = ( + currentValue: SyncSettings[keyof SyncSettings] + ): void => { + title.innerText = `${name}${ + currentValue !== this.syncClient.getSettings()[settingName] + ? " (unsaved)" + : "" + }`; + }; + + return [titleContainer, updateTitle]; + } } diff --git a/frontend/obsidian-plugin/src/views/status-bar/status-bar.scss b/frontend/obsidian-plugin/src/views/status-bar/status-bar.scss index 3762c2d9..06dfef8f 100644 --- a/frontend/obsidian-plugin/src/views/status-bar/status-bar.scss +++ b/frontend/obsidian-plugin/src/views/status-bar/status-bar.scss @@ -1,14 +1,14 @@ .sync-status { - display: flex; - gap: var(--size-4-2); + display: flex; + gap: var(--size-4-2); - * { - display: block; - } + * { + display: block; + } - .initialize-button { - padding: 0 var(--size-4-2); - background: rgba(var(--color-red-rgb), 0.4); - cursor: pointer; - } + .initialize-button { + padding: 0 var(--size-4-2); + background: rgba(var(--color-red-rgb), 0.4); + cursor: pointer; + } } diff --git a/frontend/obsidian-plugin/src/views/status-bar/status-bar.ts b/frontend/obsidian-plugin/src/views/status-bar/status-bar.ts index 6466601c..8c441f9b 100644 --- a/frontend/obsidian-plugin/src/views/status-bar/status-bar.ts +++ b/frontend/obsidian-plugin/src/views/status-bar/status-bar.ts @@ -4,72 +4,72 @@ import type { HistoryStats, SyncClient } from "sync-client"; import type VaultLinkPlugin from "../../vault-link-plugin"; export class StatusBar { - private readonly statusBarItem: HTMLElement; + private readonly statusBarItem: HTMLElement; - private lastHistoryStats: HistoryStats | undefined; - private lastRemaining: number | undefined; + private lastHistoryStats: HistoryStats | undefined; + private lastRemaining: number | undefined; - public constructor( - private readonly plugin: VaultLinkPlugin, - private readonly syncClient: SyncClient - ) { - this.statusBarItem = plugin.addStatusBarItem(); - this.syncClient.addSyncHistoryUpdateListener((status) => { - this.lastHistoryStats = status; - this.updateStatus(); - }); + public constructor( + private readonly plugin: VaultLinkPlugin, + private readonly syncClient: SyncClient + ) { + this.statusBarItem = plugin.addStatusBarItem(); + this.syncClient.onSyncHistoryUpdated.add((status) => { + this.lastHistoryStats = status; + this.updateStatus(); + }); - this.syncClient.addRemainingSyncOperationsListener( - (remainingOperations) => { - this.lastRemaining = remainingOperations; - this.updateStatus(); - } - ); + this.syncClient.onRemainingOperationsCountChanged.add( + (remainingOperations) => { + this.lastRemaining = remainingOperations; + this.updateStatus(); + } + ); - this.syncClient.addOnSettingsChangeListener(() => { - this.updateStatus(); - }); - } + this.syncClient.onSettingsChanged.add(() => { + this.updateStatus(); + }); + } - private updateStatus(): void { - this.statusBarItem.empty(); - const container = this.statusBarItem.createDiv({ - cls: ["sync-status"] - }); + private updateStatus(): void { + this.statusBarItem.empty(); + const container = this.statusBarItem.createDiv({ + cls: ["sync-status"] + }); - if (!this.syncClient.getSettings().isSyncEnabled) { - const button = container.createEl("button", { - text: "VaultLink is disabled, click to configure", - cls: "initialize-button" - }); - button.onclick = this.plugin.openSettings.bind(this.plugin); + if (!this.syncClient.getSettings().isSyncEnabled) { + const button = container.createEl("button", { + text: "VaultLink is disabled, click to configure", + cls: "initialize-button" + }); + button.onclick = this.plugin.openSettings.bind(this.plugin); - return; - } + return; + } - let hasShownMessage = false; + let hasShownMessage = false; - if ((this.lastRemaining ?? 0) > 0) { - hasShownMessage = true; - container.createSpan({ text: `${this.lastRemaining} ⏳` }); - } + if ((this.lastRemaining ?? 0) > 0) { + hasShownMessage = true; + container.createSpan({ text: `${this.lastRemaining} ⏳` }); + } - if ((this.lastHistoryStats?.success ?? 0) > 0) { - hasShownMessage = true; - container.createSpan({ - text: `${this.lastHistoryStats?.success ?? 0} ✅` - }); - } + if ((this.lastHistoryStats?.success ?? 0) > 0) { + hasShownMessage = true; + container.createSpan({ + text: `${this.lastHistoryStats?.success ?? 0} ✅` + }); + } - if ((this.lastHistoryStats?.error ?? 0) > 0) { - hasShownMessage = true; - container.createSpan({ - text: `${this.lastHistoryStats?.error ?? 0} ❌` - }); - } + if ((this.lastHistoryStats?.error ?? 0) > 0) { + hasShownMessage = true; + container.createSpan({ + text: `${this.lastHistoryStats?.error ?? 0} ❌` + }); + } - if (!hasShownMessage) { - container.createSpan({ text: "VaultLink is idle" }); - } - } + if (!hasShownMessage) { + container.createSpan({ text: "VaultLink is idle" }); + } + } } diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.scss b/frontend/obsidian-plugin/src/views/status-description/status-description.scss index 3ac86944..b66447bd 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.scss +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.scss @@ -1,32 +1,32 @@ @mixin number-card { - padding: var(--size-2-1) var(--size-4-1); - border-radius: var(--radius-s); - background-color: var(--color-base-30); - font-size: var(--font-ui-small); + padding: var(--size-2-1) var(--size-4-1); + border-radius: var(--radius-s); + background-color: var(--color-base-30); + font-size: var(--font-ui-small); - &.good { - background-color: rgba(var(--color-green-rgb), 0.35); - } + &.good { + background-color: rgba(var(--color-green-rgb), 0.35); + } - &.bad { - background-color: rgba(var(--color-red-rgb), 0.35); - } + &.bad { + background-color: rgba(var(--color-red-rgb), 0.35); + } } .status-description { - margin: var(--p-spacing) 0; + margin: var(--p-spacing) 0; - .number { - @include number-card; - font-family: var(--font-monospace); - font-weight: var(--bold-weight); - } + .number { + @include number-card; + font-family: var(--font-monospace); + font-weight: var(--bold-weight); + } - .error { - color: rgb(var(--color-red-rgb)); - } + .error { + color: rgb(var(--color-red-rgb)); + } - .warning { - color: rgb(var(--color-yellow-rgb)); - } + .warning { + color: rgb(var(--color-yellow-rgb)); + } } diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.ts b/frontend/obsidian-plugin/src/views/status-description/status-description.ts index 666c107b..6d8d74fe 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.ts +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -1,148 +1,147 @@ import "./status-description.scss"; import type { - HistoryStats, - NetworkConnectionStatus, - SyncClient + HistoryStats, + NetworkConnectionStatus, + SyncClient } from "sync-client"; +import { utils } from "sync-client"; export class StatusDescription { - private lastHistoryStats: HistoryStats | undefined; - private lastRemaining: number | undefined; - private lastConnectionState: NetworkConnectionStatus | undefined; + private lastHistoryStats: HistoryStats | undefined; + private lastRemaining: number | undefined; + private lastConnectionState: NetworkConnectionStatus | undefined; - private statusChangeListeners: (() => unknown)[] = []; + private readonly statusChangeListeners: (() => unknown)[] = []; - public constructor(private readonly syncClient: SyncClient) { - void this.updateConnectionState(); + public constructor(private readonly syncClient: SyncClient) { + void this.updateConnectionState(); - syncClient.addSyncHistoryUpdateListener((status) => { - this.lastHistoryStats = status; - this.updateDescription(); - }); + syncClient.onSyncHistoryUpdated.add((status) => { + this.lastHistoryStats = status; + this.updateDescription(); + }); - this.syncClient.addRemainingSyncOperationsListener( - (remainingOperations) => { - this.lastRemaining = remainingOperations; - this.updateDescription(); - } - ); + this.syncClient.onRemainingOperationsCountChanged.add( + (remainingOperations) => { + this.lastRemaining = remainingOperations; + this.updateDescription(); + } + ); - this.syncClient.addWebSocketStatusChangeListener(async () => - this.updateConnectionState() - ); + this.syncClient.onWebSocketStatusChanged.add(async () => + this.updateConnectionState() + ); - this.syncClient.addOnSettingsChangeListener(async () => - this.updateConnectionState() - ); - } + this.syncClient.onSettingsChanged.add(async () => + this.updateConnectionState() + ); + } - public async updateConnectionState(): Promise { - this.lastConnectionState = await this.syncClient.checkConnection(); - this.updateDescription(); - } + public async updateConnectionState(): Promise { + this.lastConnectionState = await this.syncClient.checkConnection(); + this.updateDescription(); + } - public addStatusChangeListener(listener: () => unknown): void { - this.statusChangeListeners.push(listener); - } - public removeStatusChangeListener(listener: () => unknown): void { - this.statusChangeListeners = this.statusChangeListeners.filter( - (l) => l !== listener - ); - } + public addStatusChangeListener(listener: () => unknown): void { + this.statusChangeListeners.push(listener); + } + public removeStatusChangeListener(listener: () => unknown): void { + utils.removeFromArray(this.statusChangeListeners, listener); + } - public renderStatusDescription(container: HTMLElement): void { - container.empty(); - container.addClass("status-description"); + public renderStatusDescription(container: HTMLElement): void { + container.empty(); + container.addClass("status-description"); - if (this.lastConnectionState == undefined) { - container.createSpan({ - text: "VaultLink is starting up…", - cls: "warning" - }); - return; - } + if (this.lastConnectionState == undefined) { + container.createSpan({ + text: "VaultLink is starting up…", + cls: "warning" + }); + return; + } - if (!this.lastConnectionState.isSuccessful) { - container.createSpan({ - text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`, - cls: "error" - }); - return; - } + if (!this.lastConnectionState.isSuccessful) { + container.createSpan({ + text: `VaultLink failed to connect to the remote server with error '${this.lastConnectionState.serverMessage}'`, + cls: "error" + }); + return; + } - if (!this.lastConnectionState.isWebSocketConnected) { - container.createSpan({ - text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`, - cls: "error" - }); - return; - } + if (!this.lastConnectionState.isWebSocketConnected) { + container.createSpan({ + text: `${this.lastConnectionState.serverMessage} but the WebSocket connection could not be established.`, + cls: "error" + }); + return; + } - container.createSpan({ text: "VaultLink is connected to the server " }); - container.createEl("a", { - text: this.syncClient.getSettings().remoteUri, - href: this.syncClient.getSettings().remoteUri - }); + container.createSpan({ text: "VaultLink is connected to the server " }); + container.createEl("a", { + text: this.syncClient.getSettings().remoteUri, + href: this.syncClient.getSettings().remoteUri + }); - container.createSpan({ - text: ` and has indexed approximately ` - }); - container.createSpan({ - text: `${this.syncClient.documentCount}`, - cls: "number" - }); - container.createSpan({ - text: ` documents. ` - }); + container.createSpan({ + text: ` and has indexed approximately ` + }); + container.createSpan({ + text: `${this.syncClient.syncedDocumentCount}`, + cls: "number" + }); + container.createSpan({ + text: ` documents. ` + }); - if ( - (this.lastRemaining ?? 0) === 0 && - (this.lastHistoryStats?.success ?? 0) === 0 && - (this.lastHistoryStats?.error ?? 0) === 0 - ) { - if (this.syncClient.getSettings().isSyncEnabled) { - container.createSpan({ - text: "Syncing is enabled but VaultLink hasn't found anything to sync yet." - }); - } else { - container.createSpan({ - text: "However, syncing is disabled right now.", - cls: "warning" - }); - } - return; - } + if ( + (this.lastRemaining ?? 0) === 0 && + (this.lastHistoryStats?.success ?? 0) === 0 && + (this.lastHistoryStats?.error ?? 0) === 0 + ) { + if (this.syncClient.getSettings().isSyncEnabled) { + container.createSpan({ + text: "Syncing is enabled but VaultLink hasn't found anything to sync yet." + }); + } else { + container.createSpan({ + text: "However, syncing is disabled right now.", + cls: "warning" + }); + } + return; + } - container.createSpan({ - text: "The plugin has " - }); - container.createSpan({ - text: `${this.lastRemaining ?? 0}`, - cls: "number" - }); - container.createSpan({ - text: " outstanding operations while having succeeded " - }); - container.createSpan({ - text: `${this.lastHistoryStats?.success ?? 0}`, - cls: ["number", "good"] - }); - container.createSpan({ - text: " times and failed " - }); - container.createSpan({ - text: `${this.lastHistoryStats?.error ?? 0}`, - cls: ["number", "bad"] - }); - container.createSpan({ - text: " times." - }); - } + container.createSpan({ + text: "The plugin has " + }); + container.createSpan({ + text: `${this.lastRemaining ?? 0}`, + cls: "number" + }); + container.createSpan({ + text: " outstanding operations while having succeeded " + }); + container.createSpan({ + text: `${this.lastHistoryStats?.success ?? 0}`, + cls: ["number", "good"] + }); + container.createSpan({ + text: " times and failed " + }); + container.createSpan({ + text: `${this.lastHistoryStats?.error ?? 0}`, + cls: ["number", "bad"] + }); + container.createSpan({ + text: " times." + }); + } - private updateDescription(): void { - this.statusChangeListeners.forEach((listener) => { - listener(); - }); - } + private updateDescription(): void { + this.statusChangeListeners.forEach((listener) => { + listener(); + }); + } } diff --git a/frontend/obsidian-plugin/tsconfig.json b/frontend/obsidian-plugin/tsconfig.json index 4c39e97b..7ec2a9cd 100644 --- a/frontend/obsidian-plugin/tsconfig.json +++ b/frontend/obsidian-plugin/tsconfig.json @@ -1,17 +1,12 @@ { - "compilerOptions": { - "baseUrl": ".", - "module": "ESNext", - "target": "ES2023", - "strict": true, - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "lib": [ - "DOM", - "ES2024" - ] - }, - "exclude": [ - "./dist" - ] -} \ No newline at end of file + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "ES2023", + "strict": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "lib": ["DOM", "ES2024"] + }, + "exclude": ["./dist"] +} diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js index 8b7cb411..12844fd7 100644 --- a/frontend/obsidian-plugin/webpack.config.js +++ b/frontend/obsidian-plugin/webpack.config.js @@ -4,114 +4,113 @@ const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const fs = require("fs-extra"); module.exports = (env, argv) => ({ - devtool: argv.mode === "development" ? "inline-source-map" : false, - entry: { - index: "./src/vault-link-plugin.ts" - }, - watchOptions: { - ignored: "**/node_modules" - }, - externals: { - obsidian: "commonjs obsidian", - electron: "commonjs electron", - "@codemirror/autocomplete": "commonjs @codemirror/autocomplete", - "@codemirror/collab": "commonjs @codemirror/collab", - "@codemirror/commands": "commonjs @codemirror/commands", - "@codemirror/language": "commonjs @codemirror/language", - "@codemirror/lint": "commonjs @codemirror/lint", - "@codemirror/search": "commonjs @codemirror/search", - "@codemirror/state": "commonjs @codemirror/state", - "@codemirror/view": "commonjs @codemirror/view" - }, - optimization: { - minimizer: [ - new TerserPlugin({ - terserOptions: { - module: true - } - }) - ] - }, - plugins: [ - new MiniCssExtractPlugin({ - filename: "styles.css" - }), - { - apply: (compiler) => { - if (argv.mode !== "development") { - return; - } + devtool: argv.mode === "development" ? "inline-source-map" : false, + entry: { + index: "./src/vault-link-plugin.ts" + }, + watchOptions: { + ignored: "**/node_modules" + }, + externals: { + obsidian: "commonjs obsidian", + electron: "commonjs electron", + "@codemirror/autocomplete": "commonjs @codemirror/autocomplete", + "@codemirror/collab": "commonjs @codemirror/collab", + "@codemirror/commands": "commonjs @codemirror/commands", + "@codemirror/language": "commonjs @codemirror/language", + "@codemirror/lint": "commonjs @codemirror/lint", + "@codemirror/search": "commonjs @codemirror/search", + "@codemirror/state": "commonjs @codemirror/state", + "@codemirror/view": "commonjs @codemirror/view" + }, + optimization: { + minimizer: [ + new TerserPlugin({ + terserOptions: { + module: true + } + }) + ] + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: "styles.css" + }), + { + apply: (compiler) => { + if (argv.mode !== "development") { + return; + } - compiler.hooks.done.tap("Copy Files Plugin", (stats) => { - const source = path.resolve(__dirname, "dist"); - const destinations = [ - "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", - "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link", - // "/home/andras/obsidian-test/.obsidian/plugins/vault-link" - ]; - destinations.forEach((destination) => { - fs.copy(source, destination) - .then(() => - console.log( - "Files copied successfully after build!" - ) - ) - .catch((err) => - console.error("Error copying files:", err) - ); + compiler.hooks.done.tap("Copy Files Plugin", (stats) => { + const source = path.resolve(__dirname, "dist"); + const destinations = [ + "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", + "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link" + ]; + destinations.forEach((destination) => { + fs.copy(source, destination) + .then(() => + console.log( + "Files copied successfully after build!" + ) + ) + .catch((err) => + console.error("Error copying files:", err) + ); - fs.createFile(path.join(destination, ".hotreload")); - }); - }); - } - } - ], - module: { - rules: [ - { - test: /\.json$/i, - type: "asset/resource", - generator: { - filename: "[name][ext]" - } - }, - { - test: /\.scss$/i, - use: [ - MiniCssExtractPlugin.loader, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true // required by resolve-url-loader - } - } - ] - }, - { - test: /\.ts$/, - use: ["ts-loader"] - } - ] - }, - resolve: { - extensions: [ - ".ts", - ".js" // required for development - ], - alias: { - root: __dirname, - src: path.resolve(__dirname, "src") - } - }, - output: { - clean: true, - filename: "main.js", - library: { - type: "commonjs" // required for Obsidian - }, - path: path.resolve(__dirname, "dist"), - publicPath: "" - } + fs.createFile(path.join(destination, ".hotreload")); + }); + }); + } + } + ], + module: { + rules: [ + { + test: /\.json$/i, + type: "asset/resource", + generator: { + filename: "[name][ext]" + } + }, + { + test: /\.scss$/i, + use: [ + MiniCssExtractPlugin.loader, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true // required by resolve-url-loader + } + } + ] + }, + { + test: /\.ts$/, + use: ["ts-loader"] + } + ] + }, + resolve: { + extensions: [ + ".ts", + ".js" // required for development + ], + alias: { + root: __dirname, + src: path.resolve(__dirname, "src") + } + }, + output: { + clean: true, + filename: "main.js", + library: { + type: "commonjs" // required for Obsidian + }, + path: path.resolve(__dirname, "dist"), + publicPath: "" + } }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 180a76cd..4d8218ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,4708 +1,7554 @@ { - "name": "my-workspace", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "my-workspace", - "workspaces": [ - "sync-client", - "obsidian-plugin", - "test-client", - "local-client-cli" - ], - "devDependencies": { - "concurrently": "^9.2.1", - "eslint": "9.28.0", - "eslint-plugin-unused-imports": "^4.1.4", - "npm-check-updates": "^19.1.1", - "prettier": "^3.6.2", - "typescript-eslint": "8.41.0" - } - }, - "local-client-cli": { - "version": "0.9.2", - "dependencies": { - "commander": "^12.1.0" - }, - "bin": { - "vaultlink": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^24.8.1", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } - }, - "local-client-cli/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "engines": { - "node": ">=18" - } - }, - "node_modules/@codemirror/state": { - "version": "6.5.2", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.36.4", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/state": "^6.5.0", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.6.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.17.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.20.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.28.0", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", - "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.2", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", - "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.30", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", - "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.1", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.1", - "@parcel/watcher-darwin-arm64": "2.5.1", - "@parcel/watcher-darwin-x64": "2.5.1", - "@parcel/watcher-freebsd-x64": "2.5.1", - "@parcel/watcher-linux-arm-glibc": "2.5.1", - "@parcel/watcher-linux-arm-musl": "2.5.1", - "@parcel/watcher-linux-arm64-glibc": "2.5.1", - "@parcel/watcher-linux-arm64-musl": "2.5.1", - "@parcel/watcher-linux-x64-glibc": "2.5.1", - "@parcel/watcher-linux-x64-musl": "2.5.1", - "@parcel/watcher-win32-arm64": "2.5.1", - "@parcel/watcher-win32-ia32": "2.5.1", - "@parcel/watcher-win32-x64": "2.5.1" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@plausible-analytics/tracker": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@plausible-analytics/tracker/-/tracker-0.4.0.tgz", - "integrity": "sha512-KXwttotIZymo3yGzargrsxl9hjXJo5N+Kips3ZMamYqJxJqv1Zx+POC6WOFxYwDe1iJW7T91ItQYD8mZsznpXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz", - "integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry/core": "10.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.8.0.tgz", - "integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry/core": "10.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.8.0.tgz", - "integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.8.0", - "@sentry/core": "10.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz", - "integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.8.0", - "@sentry/core": "10.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.8.0.tgz", - "integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.8.0", - "@sentry-internal/feedback": "10.8.0", - "@sentry-internal/replay": "10.8.0", - "@sentry-internal/replay-canvas": "10.8.0", - "@sentry/core": "10.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/core": { - "version": "10.8.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.8.0.tgz", - "integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/codemirror": { - "version": "5.60.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/tern": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.7", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.8.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", - "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.14.0" - } - }, - "node_modules/@types/tern": { - "version": "0.23.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", - "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/type-utils": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.41.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", - "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", - "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.41.0", - "@typescript-eslint/types": "^8.41.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", - "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", - "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", - "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", - "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", - "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.41.0", - "@typescript-eslint/tsconfig-utils": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/visitor-keys": "8.41.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", - "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.41.0", - "@typescript-eslint/types": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", - "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.41.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webpack-cli/configtest": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/info": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - } - }, - "node_modules/@webpack-cli/serve": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "webpack": "^5.82.0", - "webpack-cli": "6.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/acorn": { - "version": "8.14.1", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "license": "MIT" - }, - "node_modules/big.js": { - "version": "5.2.2", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.4", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/bufferutil": { - "version": "4.0.9", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/byte-base64": { - "version": "1.1.0", - "license": "MIT" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001707", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "2.20.3", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-loader": { - "version": "7.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "1.0.3", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.127", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.18.1", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/envinfo": { - "version": "7.14.0", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.28.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-unused-imports": { - "version": "4.1.4", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", - "eslint": "^9.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.1", - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.0.6", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-loader": { - "version": "6.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "dev": true, - "license": "ISC" - }, - "node_modules/fs-extra": { - "version": "11.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", - "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/globals": { - "version": "14.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/immutable": { - "version": "5.1.1", - "dev": true, - "license": "MIT" - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/interpret": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/loader-runner": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "dev": true, - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/local-client-cli": { - "resolved": "local-client-cli", - "link": true - }, - "node_modules/locate-path": { - "version": "6.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "dev": true, - "license": "MIT" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.2", - "dev": true, - "license": "MIT", - "dependencies": { - "schema-utils": "^4.0.0", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/ajv": { - "version": "8.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/moment": { - "version": "2.29.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "dev": true, - "license": "MIT" - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.19", - "dev": true, - "license": "MIT" - }, - "node_modules/npm-check-updates": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.1.tgz", - "integrity": "sha512-vy/uNbaK6Xfj/QzM8OXeALZak67E0uHjUlbdT1YGy4bdj0xlBU6AVd+8bscY8vlDpyzL6Y7mxcrX8kzEDeEpNg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "ncu": "build/cli.js", - "npm-check-updates": "build/cli.js" - }, - "engines": { - "node": ">=20.0.0", - "npm": ">=8.12.1" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obsidian": { - "version": "1.8.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/codemirror": "5.60.8", - "moment": "2.29.4" - }, - "peerDependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-queue": { - "version": "8.1.0", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.1", - "p-timeout": "^6.1.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-timeout": { - "version": "6.1.4", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/postcss": { - "version": "8.5.3", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.8", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "dev": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/randombytes": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rechoir": { - "version": "0.8.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/reconcile-text": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.5.0.tgz", - "integrity": "sha512-zki3lqw9Oxdhm9ZvDN17VyYoL1Isc8BEL07ILVDE2yGfNEI7thrkczoNCUr+hkFU2rzZtfxECTG0b7p61AJ6wg==", - "license": "MIT" - }, - "node_modules/regex-parser": { - "version": "2.3.1", - "dev": true, - "license": "MIT" - }, - "node_modules/request-animation-frame-timeout": { - "version": "2.0.4", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/resolve-url-loader": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "adjust-sourcemap-loader": "^4.0.0", - "convert-source-map": "^1.7.0", - "loader-utils": "^2.0.0", - "postcss": "^8.2.14", - "source-map": "0.6.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/resolve-url-loader/node_modules/convert-source-map": { - "version": "1.9.0", - "dev": true, - "license": "MIT" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.91.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", - "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/sass-loader": { - "version": "16.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "neo-async": "^2.6.2" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "node-sass": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/schema-utils": { - "version": "3.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/semver": { - "version": "7.7.2", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-mod": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/supports-color": { - "version": "8.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sync-client": { - "resolved": "sync-client", - "link": true - }, - "node_modules/tapable": { - "version": "2.2.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/terser": { - "version": "5.39.0", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "8.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/test-client": { - "resolved": "test-client", - "link": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-loader": { - "version": "9.5.2", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/source-map": { - "version": "0.7.4", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.20.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz", - "integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.25.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/type-check": { - "version": "0.4.0", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.8.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.41.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", - "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.41.0", - "@typescript-eslint/parser": "8.41.0", - "@typescript-eslint/typescript-estree": "8.41.0", - "@typescript-eslint/utils": "8.41.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url": { - "version": "0.11.4", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^1.4.1", - "qs": "^6.12.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/url/node_modules/punycode": { - "version": "1.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/utf-8-validate": { - "version": "6.0.5", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/uuid": { - "version": "11.1.0", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/vault-link-obsidian-plugin": { - "resolved": "obsidian-plugin", - "link": true - }, - "node_modules/virtual-scroller": { - "version": "1.13.1", - "dev": true, - "license": "MIT", - "dependencies": { - "request-animation-frame-timeout": "^2.0.3" - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/watchpack": { - "version": "2.4.2", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack": { - "version": "5.99.9", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-cli": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.6.1", - "@webpack-cli/configtest": "^3.0.1", - "@webpack-cli/info": "^3.0.1", - "@webpack-cli/serve": "^3.0.1", - "colorette": "^2.0.14", - "commander": "^12.1.0", - "cross-spawn": "^7.0.3", - "envinfo": "^7.14.0", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^6.0.1" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.82.0" - }, - "peerDependenciesMeta": { - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/webpack-cli/node_modules/colorette": { - "version": "2.0.20", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "12.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/webpack-merge": { - "version": "6.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.2.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.17.1", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.2", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/which": { - "version": "2.0.2", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "obsidian-plugin": { - "name": "vault-link-obsidian-plugin", - "version": "0.9.2", - "license": "MIT", - "devDependencies": { - "@plausible-analytics/tracker": "^0.4.0", - "@sentry/browser": "^10.8.0", - "@types/node": "^24.8.1", - "css-loader": "^7.1.2", - "date-fns": "^4.1.0", - "file-loader": "^6.2.0", - "fs-extra": "^11.3.0", - "mini-css-extract-plugin": "^2.9.2", - "obsidian": "1.8.7", - "reconcile-text": "^0.5.0", - "resolve-url-loader": "^5.0.0", - "sass": "^1.91.0", - "sass-loader": "^16.0.5", - "sync-client": "file:../sync-client", - "terser-webpack-plugin": "^5.3.14", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "url": "^0.11.4", - "virtual-scroller": "^1.13.1", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } - }, - "sync-client": { - "version": "0.9.2", - "dependencies": { - "byte-base64": "^1.1.0", - "minimatch": "^10.0.1", - "p-queue": "^8.1.0", - "reconcile-text": "^0.5.0", - "uuid": "^11.1.0" - }, - "devDependencies": { - "@types/node": "^24.8.1", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1", - "webpack-merge": "^6.0.1", - "ws": "^8.18.3" - } - }, - "sync-client/node_modules/brace-expansion": { - "version": "2.0.1", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "sync-client/node_modules/minimatch": { - "version": "10.0.1", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "test-client": { - "version": "0.9.2", - "bin": { - "test-client": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^24.8.1", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "uuid": "^11.1.0", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } - } - } + "name": "my-workspace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-workspace", + "workspaces": [ + "sync-client", + "obsidian-plugin", + "test-client", + "local-client-cli" + ], + "devDependencies": { + "concurrently": "^9.2.1", + "eclint": "^2.8.1", + "eslint": "9.38.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^19.1.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" + } + }, + "local-client-cli": { + "version": "0.14.0", + "dependencies": { + "commander": "^14.0.2", + "watcher": "^2.3.1" + }, + "bin": { + "vaultlink": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.8.1", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz", + "integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/core": "10.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.8.0.tgz", + "integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/core": "10.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.8.0.tgz", + "integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.8.0", + "@sentry/core": "10.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz", + "integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.8.0", + "@sentry/core": "10.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.8.0.tgz", + "integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.8.0", + "@sentry-internal/feedback": "10.8.0", + "@sentry-internal/replay": "10.8.0", + "@sentry-internal/replay-canvas": "10.8.0", + "@sentry/core": "10.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.8.0.tgz", + "integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", + "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.14.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.41.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.41.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-cyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-cyan/-/ansi-cyan-0.1.1.tgz", + "integrity": "sha512-eCjan3AVo/SxZ0/MyIYRtkpxIu/H3xZN7URr1vXVrISxeyz8fUFz0FJziamK4sS8I+t35y4rHg1b2PklyBe/7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha512-HrgGIZUl8h2EHuZaU9hTR/cU5nhKxpVE1V6kdGsQ8e4zirElJ5fvtfc8N7Q1oq1aatO275i8pUFUCpNWCAnVWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/append-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/append-buffer/-/append-buffer-1.0.2.tgz", + "integrity": "sha512-WLbYiXzD3y/ATLZFufV/rZvWdZOs+Z/+5v1rBZ463Jn398pa6kcde27cvozYnBoxXblGZTFfoPpsaEw0orU5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha512-rlVfZW/1Ph2SNySXwR9QYkChp8EkOEiTMO5Vwx60usw04i4nWemkm9RXmQqgkQFaLHsqLuADvjp6IfgL9l2M8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bignumber.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.4.0.tgz", + "integrity": "sha512-uw4ra6Cv483Op/ebM0GBKKfxZlSmn6NgFRby5L3yGTlunLj53KQgndDlqy2WVFOwgvurocApYkSud0aO+mvrpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equals": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz", + "integrity": "sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/buffered-spawn": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/buffered-spawn/-/buffered-spawn-3.3.2.tgz", + "integrity": "sha512-YVdiyWEbFCH+lu3USRFoH6UtvS3mr/e/obxZNbOkbbL3heLEUYb3YpTjKUQFWt5d3k9ZILabY8Kh2pp+i4SQqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/buffered-spawn/node_modules/cross-spawn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "node_modules/buffered-spawn/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/bufferstreams": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-2.0.1.tgz", + "integrity": "sha512-ZswyIoBfFb3cVDsnZLLj2IDJ/0ppYdil/v2EGlZXvoefO689FokEmFEldhN5dV7R2QBxFneqTJOMIpfqhj+n0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.6" + }, + "engines": { + "node": ">=6.9.5" + } + }, + "node_modules/byte-base64": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001707", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/checkstyle-formatter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/checkstyle-formatter/-/checkstyle-formatter-1.1.0.tgz", + "integrity": "sha512-mak+5ooX5cDFBBIhsR+NqxoQ9+JQRqupr49G2PiUYXKn8OntoI9osjhECaScrzqq1l4phuRmK1VlMdxHdpwZvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-escape": "^1.0.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-1.1.0.tgz", + "integrity": "sha512-bAtZo0u82gCfaAGfSNxUdTI9mNyza7D8w4CVCcaOsy7sgwDzvx6ekr6cuWJqY3UGzgnQ1+4wgENup5eIhgxEYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^1.0.0", + "string-width": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", + "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clone-stats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", + "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/cloneable-readable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", + "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "process-nextick-args": "^2.0.0", + "readable-stream": "^2.3.5" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "7.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-format": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-0.0.2.tgz", + "integrity": "sha512-M4obuJx8jU5T91lcbwi0+QPNVaWOY1DQYz5xUuKYWO93osVzB2ZPqyDUc5T+mDjbA1X8VOb4JDZ+8r2MrSOp7Q==", + "deprecated": "0.x is no longer supported. Please upgrade to 4.x or higher.", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/eclint": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eclint/-/eclint-2.8.1.tgz", + "integrity": "sha512-0u1UubFXSOgZgXNhuPeliYyTFmjWStVph8JR6uD6NDuxl3xI5VSCsA1KX6/BSYtM9v4wQMifGoNFfN5VlRn4LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "editorconfig": "^0.15.2", + "file-type": "^10.1.0", + "gulp-exclude-gitignore": "^1.2.0", + "gulp-filter": "^5.1.0", + "gulp-reporter": "^2.9.0", + "gulp-tap": "^1.0.1", + "linez": "^4.1.4", + "lodash": "^4.17.11", + "minimatch": "^3.0.4", + "os-locale": "^3.0.1", + "plugin-error": "^1.0.1", + "through2": "^2.0.3", + "vinyl": "^2.2.0", + "vinyl-fs": "^3.0.3", + "yargs": "^12.0.2" + }, + "bin": { + "eclint": "bin/eclint.js" + } + }, + "node_modules/eclint/node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/eclint/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true, + "license": "ISC" + }, + "node_modules/eclint/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eclint/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/eclint/node_modules/yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "node_modules/eclint/node_modules/yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/editorconfig": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", + "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + }, + "bin": { + "editorconfig": "bin/editorconfig" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.127", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/emphasize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emphasize/-/emphasize-2.1.0.tgz", + "integrity": "sha512-wRlO0Qulw2jieQynsS3STzTabIhHCyjTjZraSkchOiT8rdvWZlahJAJ69HRxwGkv2NThmci2MSnDfJ60jB39tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.0", + "highlight.js": "~9.12.0", + "lowlight": "~1.9.0" + } + }, + "node_modules/emphasize/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/emphasize/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emphasize/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/emphasize/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.14.0", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.1.4", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fancy-log": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", + "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "parse-node-version": "^1.0.0", + "time-stamp": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dev": true, + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-type": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", + "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "dev": true, + "license": "ISC" + }, + "node_modules/flush-write-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" + } + }, + "node_modules/follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "=3.1.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/follow-redirects/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/follow-redirects/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-mkdirp-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", + "integrity": "sha512-+vSd9frUnapVC2RZYfL3FCB2p3g4TBhaUmrsWlSudsGdnxIuUvBB2QM1VZeBtc49QFwrp+wQLrDs3+xxDgI5gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-stream": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", + "integrity": "sha512-uMbLGAP3S2aDOHUDfdoYcdIePUCfysbAd0IAoWVZbeGU/oNQ8asHVSshLDJUPWxfzj8zsCG7/XeHPHTtow0nsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.0", + "glob": "^7.1.1", + "glob-parent": "^3.1.0", + "is-negated-glob": "^1.0.0", + "ordered-read-streams": "^1.0.0", + "pumpify": "^1.3.5", + "readable-stream": "^2.1.5", + "remove-trailing-separator": "^1.0.1", + "to-absolute-glob": "^2.0.0", + "unique-stream": "^2.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/glob-stream/node_modules/glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + } + }, + "node_modules/glob-stream/node_modules/is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-exclude-gitignore": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gulp-exclude-gitignore/-/gulp-exclude-gitignore-1.2.0.tgz", + "integrity": "sha512-J3LCmz9C1UU1pxf5Npx6SNc5o9YQptyc9IHaqLiBlihZmg44jaaTplWUZ0JPQkMdOTae0YgEDvT9TKlUWDSMUA==", + "dev": true, + "license": "ISC", + "dependencies": { + "gulp-ignore": "^2.0.2" + } + }, + "node_modules/gulp-filter": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/gulp-filter/-/gulp-filter-5.1.0.tgz", + "integrity": "sha512-ZERu1ipbPmjrNQ2dQD6lL4BjrJQG66P/c5XiyMMBqV+tUAJ+fLOyYIL/qnXd2pHmw/G/r7CLQb9ttANvQWbpfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "multimatch": "^2.0.0", + "plugin-error": "^0.1.2", + "streamfilter": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-filter/node_modules/arr-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-1.1.0.tgz", + "integrity": "sha512-OQwDZUqYaQwyyhDJHThmzId8daf4/RFNLaeh3AevmSeZ5Y7ug4Ga/yKc6l6kTZOBW781rCj103ZuTh8GAsB3+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.0.1", + "array-slice": "^0.2.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/arr-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-2.1.0.tgz", + "integrity": "sha512-t5db90jq+qdgk8aFnxEkjqta0B/GHrM1pxzuuZz2zWsOXc5nKu3t+76s/PQBA8FTcM/ipspIH9jWG4OxCBc2eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/extend-shallow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-1.1.4.tgz", + "integrity": "sha512-L7AGmkO6jhDkEBBGWlLtftA80Xq8DipnrRPr0pyi7GQLXkaq9JYA4xF4z6qnadIC6euiTDKco0cGSU9muw+WTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/kind-of": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "integrity": "sha512-aUH6ElPnMGon2/YkxRIigV32MOpTVcoXQ1Oo8aYn40s+sJ3j+0gFZsT8HKDcxNy7Fi9zuquWtGaGAahOdv5p/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/plugin-error": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz", + "integrity": "sha512-WzZHcm4+GO34sjFMxQMqZbsz3xiNEgonCskQ9v+IroMmYgk/tas8dG+Hr2D6IbRPybZ12oWpzE/w3cGJ6FJzOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-cyan": "^0.1.1", + "ansi-red": "^0.1.1", + "arr-diff": "^1.0.1", + "arr-union": "^2.0.1", + "extend-shallow": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-ignore": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gulp-ignore/-/gulp-ignore-2.0.2.tgz", + "integrity": "sha512-KGtd/qgp0FLDlei986/aZ5xSyw1cqJ2BsiaWht0L0PzaQXxYKRCMkFcDPQ8fQx6JVA6Gx9OefmBFzxTtop5hMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "gulp-match": "^1.0.3", + "through2": "^2.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/gulp-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/gulp-match/-/gulp-match-1.1.0.tgz", + "integrity": "sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.3" + } + }, + "node_modules/gulp-reporter": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/gulp-reporter/-/gulp-reporter-2.10.0.tgz", + "integrity": "sha512-HeruxN7TL/enOB+pJfFmeekVsXsZzQvVGpL7vOLdUe7y7VdqHUvMQRRW5qMIvVSKqRs3EtQiR/kURu3WWfXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^3.1.0", + "axios": "^0.18.0", + "buffered-spawn": "^3.3.2", + "bufferstreams": "^2.0.1", + "chalk": "^2.4.1", + "checkstyle-formatter": "^1.1.0", + "ci-info": "^2.0.0", + "cli-truncate": "^1.1.0", + "emphasize": "^2.0.0", + "fancy-log": "^1.3.3", + "fs-extra": "^7.0.1", + "in-gfw": "^1.2.0", + "is-windows": "^1.0.2", + "js-yaml": "^3.12.0", + "junit-report-builder": "^1.3.1", + "lodash.get": "^4.4.2", + "os-locale": "^3.0.1", + "plugin-error": "^1.0.1", + "string-width": "^3.0.0", + "term-size": "^1.2.0", + "through2": "^3.0.0", + "to-time": "^1.0.2" + } + }, + "node_modules/gulp-reporter/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gulp-reporter/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/gulp-reporter/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-reporter/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-reporter/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/gulp-reporter/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/gulp-reporter/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gulp-reporter/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/gulp-reporter/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, + "node_modules/gulp-reporter/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/gulp-tap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gulp-tap/-/gulp-tap-1.0.1.tgz", + "integrity": "sha512-VpCARRSyr+WP16JGnoIg98/AcmyQjOwCpQgYoE35CWTdEMSbpgtAIK2fndqv2yY7aXstW27v3ZNBs0Ltb0Zkbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "^2.0.3" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.12.0.tgz", + "integrity": "sha512-qNnYpBDO/FQwYVur1+sQBQw7v0cxso1nOYLklqWh6af8ROwwTVoII5+kf/BVa8354WL4ad6rURHYGUXCbD9mMg==", + "deprecated": "Version no longer supported. Upgrade to @latest", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/in-gfw": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/in-gfw/-/in-gfw-1.2.0.tgz", + "integrity": "sha512-LgSoQXzuSS/x/nh0eIggq7PsI7gs/sQdXNEolRmHaFUj6YMFmPO1kxQ7XpcT3nPpC3DMwYiJmgnluqJmFXYiMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.1.2", + "is-wsl": "^1.1.0", + "mem": "^3.0.1" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-negated-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", + "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-valid-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", + "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/junit-report-builder": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/junit-report-builder/-/junit-report-builder-1.3.3.tgz", + "integrity": "sha512-75bwaXjP/3ogyzOSkkcshXGG7z74edkJjgTZlJGAyzxlOHaguexM3VLG6JyD9ZBF8mlpgsUPB1sIWU4LISgeJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "0.0.2", + "lodash": "^4.17.15", + "mkdirp": "^0.5.0", + "xmlbuilder": "^10.0.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "invert-kv": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lead": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lead/-/lead-1.0.0.tgz", + "integrity": "sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow==", + "dev": true, + "license": "MIT", + "dependencies": { + "flush-write-stream": "^1.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/linez": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/linez/-/linez-4.1.4.tgz", + "integrity": "sha512-TsqcAfotPMB9xodBIklBaJz3sRIXtkca8Kv/MO8nzAufsitCKRoYWU5MZccdCVYB81tGexYHRsrSIEiJsQhpVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equals": "^1.0.4", + "iconv-lite": "^0.4.15" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/local-client-cli": { + "resolved": "local-client-cli", + "link": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/lowlight": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.9.2.tgz", + "integrity": "sha512-Ek18ElVCf/wF/jEm1b92gTnigh94CtBNWiZ2ad+vTgW7cTmQxUY3I98BjHK68gZAJEWmybGBZgx9qv3QxLQB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fault": "^1.0.2", + "highlight.js": "~9.12.0" + } + }, + "node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mem": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-3.0.1.tgz", + "integrity": "sha512-QKs47bslvOE0NbXOqG6lMxn6Bk0Iuw0vfrIeLykmQle2LkCw1p48dZDdzE+D88b/xqRJcZGcMNeDvSVma+NuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0", + "p-is-promise": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/multimatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", + "integrity": "sha512-0mzK8ymiWdehTBiJh0vClAzGyQbdtyWqzSVx//EK4N/D+599RFlGfTAsKw2zMSABtDG9C6Ul2+t8f2Lbdjf5mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-differ": "^1.0.0", + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "minimatch": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/now-and-later": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", + "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.3.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/npm-check-updates": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.1.tgz", + "integrity": "sha512-vy/uNbaK6Xfj/QzM8OXeALZak67E0uHjUlbdT1YGy4bdj0xlBU6AVd+8bscY8vlDpyzL6Y7mxcrX8kzEDeEpNg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=8.12.1" + } + }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obsidian": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.2.tgz", + "integrity": "sha512-bX03YCHf06OTzI/D+QK71ajCPCmwr/cjxzlVXjQa10DjK5iHRWhtJJpp83arSCyayFMp23u+UHcY7hxcEx2Mvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ordered-read-streams": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", + "integrity": "sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.1" + } + }, + "node_modules/os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-is-promise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/promise-make-counter": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/promise-make-counter/-/promise-make-counter-1.0.2.tgz", + "integrity": "sha512-FJAxTBWQuQoAs4ZOYuKX1FHXxEgKLEzBxUvwr4RoOglkTpOjWuM+RXsK3M9q5lMa8kjqctUrhwYeZFT4ygsnag==", + "license": "MIT", + "dependencies": { + "promise-make-naked": "^3.0.2" + } + }, + "node_modules/promise-make-naked": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-make-naked/-/promise-make-naked-3.0.2.tgz", + "integrity": "sha512-B+b+kQ1YrYS7zO7P7bQcoqqMUizP06BOyNSBEnB5VJKDSWo8fsVuDkfSmwdjF0JsRtaNh83so5MMFJ95soH5jg==", + "license": "MIT" + }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "node_modules/pumpify/node_modules/pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/reconcile-text": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.8.0.tgz", + "integrity": "sha512-evskVha3YgpP2ZelsFxP9t7CuKnwE7TrsH3FdrH2mfKbzjUWiNF7scHXsFbFS921lmFlAOB94DHNAWPvL34Mqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/regex-parser": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/remove-bom-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", + "integrity": "sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5", + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remove-bom-buffer/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/remove-bom-stream": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz", + "integrity": "sha512-wigO8/O08XHb8YPzpDDT+QmRANfW6vLqxfaXm1YXhnFf3AkSLyjfG3GEFg4McZkmgL7KvCj5u2KczkvSP6NfHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-bom-buffer": "^3.0.0", + "safe-buffer": "^5.1.0", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/replace-ext": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", + "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", + "dev": true, + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.10", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-options": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", + "integrity": "sha512-NYDgziiroVeDC29xq7bp/CacZERYsA9bXYd1ZmcJlF3BcrZv5pTb4NG7SjdyKDnXZ84aC4vo2u6sNKIA1LCu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "value-or-function": "^3.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.6", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.6.tgz", + "integrity": "sha512-sglGzId5gmlfxNs4gK2U3h7HlVRfx278YK6Ono5lwzuvi1jxig80YiuHkaDBVsYIKFhx8wN7XSCI0M2IDS/3qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamfilter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/streamfilter/-/streamfilter-1.0.7.tgz", + "integrity": "sha512-Gk6KZM+yNA1JpW0KzlZIhjo3EaBJDkYfXtYSbOwNIQ7Zd6006E6+sCFlW1NDvFG/vnXhKmw6TJJgiEQg/8lXfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sync-client": { + "resolved": "sync-client", + "link": true + }, + "node_modules/tapable": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/term-size": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz", + "integrity": "sha512-7dPUZQGy/+m3/wjVz3ZW5dobSoD/02NxJpoXUX0WIyjfVS3l0c+b/+9phIDFA7FHzkYtwtMFgeGZ/Y8jVTeqQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^0.7.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "node_modules/term-size/node_modules/execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/term-size/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/term-size/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/terser": { + "version": "5.39.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-client": { + "resolved": "test-client", + "link": true + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2-filter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", + "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "~2.0.0", + "xtend": "~4.0.0" + } + }, + "node_modules/time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-readdir": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", + "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", + "license": "MIT", + "dependencies": { + "promise-make-counter": "^1.0.2" + } + }, + "node_modules/to-absolute-glob": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", + "integrity": "sha512-rtwLUQEwT8ZeKQbyFJyomBRYXyE16U5VKuy0ftxLMK/PZb2fkOsg5r9kHdauuVDbsNdIBoC/HCthpidamQFXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-through": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz", + "integrity": "sha512-+QIz37Ly7acM4EMdw2PRN389OneM5+d844tirkGp4dPKzI5OE72V9OsbFp+CIYJDahZ41ZV05hNtcPAQUAm9/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/to-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/to-time/-/to-time-1.0.2.tgz", + "integrity": "sha512-+wqaiQvnido2DI1bpiQ/Zv1LiOE9Fd0v35ySnNeqFmKNYJTJY/+ENI+3sHXCMzbAAOR/43aNyLM0XTpi0/zSQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^2.4.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-loader": { + "version": "9.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", + "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.41.0", + "@typescript-eslint/parser": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/undici-types": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.4.0.tgz", + "integrity": "sha512-V6QarSfeSgDipGA9EZdoIzu03ZDlOFkk+FbEP5cwgrZXN3iIkYR91IjU2EnM6rB835kGQsqHX8qncObTXV+6KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-stable-stringify-without-jsonify": "^1.0.1", + "through2-filter": "3.0.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url": { + "version": "0.11.4", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^1.4.1", + "qs": "^6.12.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/url/node_modules/punycode": { + "version": "1.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/value-or-function": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", + "integrity": "sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vault-link-obsidian-plugin": { + "resolved": "obsidian-plugin", + "link": true + }, + "node_modules/vinyl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", + "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^2.1.1", + "clone-buffer": "^1.0.0", + "clone-stats": "^1.0.0", + "cloneable-readable": "^1.0.0", + "remove-trailing-separator": "^1.0.1", + "replace-ext": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vinyl-fs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", + "integrity": "sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-mkdirp-stream": "^1.0.0", + "glob-stream": "^6.1.0", + "graceful-fs": "^4.0.0", + "is-valid-glob": "^1.0.0", + "lazystream": "^1.0.0", + "lead": "^1.0.0", + "object.assign": "^4.0.4", + "pumpify": "^1.3.5", + "readable-stream": "^2.3.3", + "remove-bom-buffer": "^3.0.0", + "remove-bom-stream": "^1.2.0", + "resolve-options": "^1.1.0", + "through2": "^2.0.0", + "to-through": "^2.0.0", + "value-or-function": "^3.0.0", + "vinyl": "^2.0.0", + "vinyl-sourcemap": "^1.1.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vinyl-sourcemap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", + "integrity": "sha512-NiibMgt6VJGJmyw7vtzhctDcfKch4e4n9TBeoWlirb7FMg9/1Ov9k+A5ZRAtywBpRPiyECvQRQllYM8dECegVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "append-buffer": "^1.0.2", + "convert-source-map": "^1.5.0", + "graceful-fs": "^4.1.6", + "normalize-path": "^2.1.1", + "now-and-later": "^2.0.0", + "remove-bom-buffer": "^3.0.0", + "vinyl": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/watcher": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/watcher/-/watcher-2.3.1.tgz", + "integrity": "sha512-d3yl+ey35h05r5EFP0TafE2jsmQUJ9cc2aernRVyAkZiu0J3+3TbNugNcqdUJDoWOfL2p+bNsN427stsBC/HnA==", + "dependencies": { + "dettle": "^1.0.2", + "stubborn-fs": "^1.2.5", + "tiny-readdir": "^2.7.2" + } + }, + "node_modules/watchpack": { + "version": "2.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.99.9", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/colorette": { + "version": "2.0.20", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.17.1", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/wildcard": { + "version": "2.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==", + "dev": true, + "license": "MIT License" + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "obsidian-plugin": { + "name": "vault-link-obsidian-plugin", + "version": "0.14.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^24.8.1", + "css-loader": "^7.1.2", + "date-fns": "^4.1.0", + "file-loader": "^6.2.0", + "fs-extra": "^11.3.0", + "mini-css-extract-plugin": "^2.9.2", + "obsidian": "1.10.2", + "reconcile-text": "^0.8.0", + "resolve-url-loader": "^5.0.0", + "sass": "^1.91.0", + "sass-loader": "^16.0.6", + "sync-client": "file:../sync-client", + "terser-webpack-plugin": "^5.3.14", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "url": "^0.11.4", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } + }, + "sync-client": { + "version": "0.14.0", + "devDependencies": { + "@sentry/browser": "^10.8.0", + "@types/node": "^24.8.1", + "byte-base64": "^1.1.0", + "minimatch": "^10.0.1", + "p-queue": "^8.1.0", + "reconcile-text": "^0.8.0", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "uuid": "^13.0.0", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1", + "webpack-merge": "^6.0.1", + "ws": "^8.18.3" + } + }, + "sync-client/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "sync-client/node_modules/minimatch": { + "version": "10.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "test-client": { + "version": "0.14.0", + "bin": { + "test-client": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^24.8.1", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "uuid": "^13.0.0", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } + } + } } diff --git a/frontend/package.json b/frontend/package.json index e105b2fe..0dd9057d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,31 +1,33 @@ { - "name": "my-workspace", - "private": true, - "workspaces": [ - "sync-client", - "obsidian-plugin", - "test-client", - "local-client-cli" - ], - "prettier": { - "trailingComma": "none", - "tabWidth": 4, - "useTabs": true, - "endOfLine": "lf" - }, - "scripts": { - "build": "npm run build --workspaces", - "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", - "test": "npm run test --workspaces", - "lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", - "update": "ncu -u -ws" - }, - "devDependencies": { - "concurrently": "^9.2.1", - "eslint": "9.28.0", - "eslint-plugin-unused-imports": "^4.1.4", - "npm-check-updates": "^19.1.1", - "prettier": "^3.6.2", - "typescript-eslint": "8.41.0" - } + "name": "my-workspace", + "private": true, + "workspaces": [ + "sync-client", + "obsidian-plugin", + "test-client", + "deterministic-tests", + "local-client-cli" + ], + "prettier": { + "trailingComma": "none", + "tabWidth": 4, + "useTabs": false, + "endOfLine": "lf" + }, + "scripts": { + "build": "npm run build --workspaces", + "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", + "test": "npm run test --workspaces", + "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"", + "update": "ncu -u -ws" + }, + "devDependencies": { + "concurrently": "^9.2.1", + "eclint": "^2.8.1", + "eslint": "9.38.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^19.1.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" + } } diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index ca2735a2..aa369fa7 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -1,6 +1,6 @@ { "name": "sync-client", - "version": "0.9.2", + "version": "0.14.0", "main": "dist/sync-client.node.js", "browser": "dist/sync-client.web.js", "types": "dist/types/index.d.ts", @@ -10,24 +10,23 @@ "scripts": { "dev": "webpack watch --mode development", "build": "webpack --mode production", - "test": "tsx --test src/**/*.test.ts" + "test": "tsx --test 'src/**/*.test.ts'" }, - "dependencies": { + "devDependencies": { "byte-base64": "^1.1.0", "minimatch": "^10.0.1", "p-queue": "^8.1.0", - "reconcile-text": "^0.5.0", - "uuid": "^11.1.0" - }, - "devDependencies": { + "reconcile-text": "^0.8.0", + "uuid": "^13.0.0", "@types/node": "^24.8.1", "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.20.5", + "tsx": "^4.20.6", "typescript": "5.8.3", "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", + "@sentry/browser": "^10.8.0", "ws": "^8.18.3" } } diff --git a/frontend/sync-client/src/consts.ts b/frontend/sync-client/src/consts.ts new file mode 100644 index 00000000..da70ba47 --- /dev/null +++ b/frontend/sync-client/src/consts.ts @@ -0,0 +1,6 @@ +export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60; +export const DIFF_CACHE_SIZE_MB = 2; +export const MAX_LOG_MESSAGE_COUNT = 100000; +export const MAX_HISTORY_ENTRY_COUNT = 5000; +export const SUPPORTED_API_VERSION = 2; +export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10; diff --git a/frontend/sync-client/src/debugging/log-to-console.ts b/frontend/sync-client/src/debugging/log-to-console.ts deleted file mode 100644 index ace58db0..00000000 --- a/frontend/sync-client/src/debugging/log-to-console.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SyncClient } from "../sync-client"; -import type { LogLine } from "../tracing/logger"; -import { LogLevel } from "../tracing/logger"; - -export function logToConsole(client: SyncClient): void { - client.logger.addOnMessageListener((logLine: LogLine) => { - const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; - - switch (logLine.level) { - case LogLevel.ERROR: - console.error(formatted); - break; - case LogLevel.WARNING: - console.warn(formatted); - break; - case LogLevel.INFO: - console.info(formatted); - break; - case LogLevel.DEBUG: - console.debug(formatted); - break; - } - }); -} diff --git a/frontend/sync-client/src/debugging/slow-fetch-factory.ts b/frontend/sync-client/src/debugging/slow-fetch-factory.ts deleted file mode 100644 index cd07dd1a..00000000 --- a/frontend/sync-client/src/debugging/slow-fetch-factory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { sleep } from "../utils/sleep"; - -export const slowFetchFactory = - (jitterScaleInSeconds: number) => - async ( - input: string | URL | globalThis.Request, - init?: RequestInit - ): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - - const response = await fetch(input, init); - - return response; - }; diff --git a/frontend/sync-client/src/debugging/slow-web-socket-factory.ts b/frontend/sync-client/src/debugging/slow-web-socket-factory.ts deleted file mode 100644 index 51a27a5f..00000000 --- a/frontend/sync-client/src/debugging/slow-web-socket-factory.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { sleep } from "../utils/sleep"; -import { Locks } from "../utils/locks"; -import type { Logger } from "../tracing/logger"; - -export function slowWebSocketFactory( - jitterScaleInSeconds: number, - logger: Logger -): typeof WebSocket { - // eslint-disable-next-line - return class FlakyWebSocket extends WebSocket { - private static readonly RECEIVE_KEY = "websocket-receive"; - private static readonly SEND_KEY = "websocket-send"; - - private readonly locks = new Locks(logger); - - public set onopen(callback: (event: Event) => void) { - super.onopen = async (event: Event): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - - callback(event); - }; - } - - public set onmessage(callback: (event: MessageEvent) => void) { - super.onmessage = async (event: MessageEvent): Promise => { - await this.locks.withLock( - FlakyWebSocket.RECEIVE_KEY, - async () => { - if (jitterScaleInSeconds > 0) { - await sleep( - Math.random() * jitterScaleInSeconds * 1000 - ); - } - - callback(event); - } - ); - }; - } - - public set onclose(callback: (event: CloseEvent) => void) { - super.onclose = async (event: CloseEvent): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - callback(event); - }; - } - - public set onerror(callback: (event: Event) => void) { - super.onerror = async (event: Event): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - callback(event); - }; - } - - public send( - data: string | ArrayBufferLike | Blob | ArrayBufferView - ): void { - this.waitingSend(data).catch((error: unknown) => { - logger.error(`Error sending WebSocket message: ${error}`); - }); - } - - private async waitingSend( - data: string | ArrayBufferLike | Blob | ArrayBufferView - ): Promise { - // maintain message order - await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - super.send(data); - }); - } - } as unknown as typeof WebSocket; -} diff --git a/frontend/sync-client/src/file-operations/file-not-found-error.ts b/frontend/sync-client/src/file-operations/file-not-found-error.ts index 63af7dab..b8acd265 100644 --- a/frontend/sync-client/src/file-operations/file-not-found-error.ts +++ b/frontend/sync-client/src/file-operations/file-not-found-error.ts @@ -1,6 +1,9 @@ export class FileNotFoundError extends Error { - public constructor(message: string) { - super(message); - this.name = "FileNotFoundError"; - } + public constructor( + message: string, + public readonly filePath: string + ) { + super(message); + this.name = "FileNotFoundError"; + } } diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 675fdce1..998e47ec 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -1,162 +1,235 @@ import { describe, it } from "node:test"; import type { - Database, - DocumentRecord, - RelativePath + Database, + DocumentRecord, + RelativePath } from "../persistence/database"; import { FileOperations } from "./file-operations"; import { Logger } from "../tracing/logger"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; +import type { ServerConfig, ServerConfigData } from "../services/server-config"; + +class MockServerConfig implements Pick { + public async getConfig(): Promise { + return { + mergeableFileExtensions: ["md", "txt"], + supportedApiVersion: 1, + isAuthenticated: true + }; + } +} class MockDatabase implements Partial { - public getLatestDocumentByRelativePath( - _find: RelativePath - ): DocumentRecord | undefined { - // no-op - return undefined; - } + public getLatestDocumentByRelativePath( + _find: RelativePath + ): DocumentRecord | undefined { + // no-op + return undefined; + } - public move( - _oldRelativePath: RelativePath, - _newRelativePath: RelativePath - ): void { - // no-op - } + public move( + _oldRelativePath: RelativePath, + _newRelativePath: RelativePath + ): void { + // no-op + } } class FakeFileSystemOperations implements FileSystemOperations { - public readonly names = new Set(); + public readonly names = new Set(); - public async listFilesRecursively( - _root: RelativePath | undefined - ): Promise { - return ["file.md"]; - } - public async read(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async write( - path: RelativePath, - _content: Uint8Array - ): Promise { - this.names.add(path); - } - public async atomicUpdateText( - _path: RelativePath, - _updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - throw new Error("Method not implemented."); - } - public async getFileSize(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async getModificationTime(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async exists(path: RelativePath): Promise { - return this.names.has(path); - } - public async createDirectory(_path: RelativePath): Promise { - // this is called but irrelevant for this mock - } - public async delete(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - this.names.delete(oldPath); - this.names.add(newPath); - } + public async listFilesRecursively( + _root: RelativePath | undefined + ): Promise { + return ["file.md"]; + } + public async read(_path: RelativePath): Promise { + throw new Error("Method not implemented."); + } + public async write( + path: RelativePath, + _content: Uint8Array + ): Promise { + this.names.add(path); + } + public async atomicUpdateText( + _path: RelativePath, + _updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + throw new Error("Method not implemented."); + } + public async getFileSize(_path: RelativePath): Promise { + throw new Error("Method not implemented."); + } + public async getModificationTime(_path: RelativePath): Promise { + throw new Error("Method not implemented."); + } + public async exists(path: RelativePath): Promise { + return this.names.has(path); + } + public async createDirectory(_path: RelativePath): Promise { + // this is called but irrelevant for this mock + } + public async delete(_path: RelativePath): Promise { + throw new Error("Method not implemented."); + } + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + this.names.delete(oldPath); + this.names.add(newPath); + } } describe("File operations", () => { - it("should deconflict renames", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations - ); + it("should deconflict renames", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); - await fileOperations.create("a", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "a"); - await fileOperations.move("a", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b"); + await fileOperations.create("a", new Uint8Array()); + assertSetContainsExactly(fileSystemOperations.names, "a"); + await fileOperations.move("a", "b"); + assertSetContainsExactly(fileSystemOperations.names, "b"); - await fileOperations.create("c", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "b", "c"); + await fileOperations.create("c", new Uint8Array()); + assertSetContainsExactly(fileSystemOperations.names, "b", "c"); - await fileOperations.move("c", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); + await fileOperations.move("c", "b"); + assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); - await fileOperations.create("c", new Uint8Array()); - await fileOperations.move("c", "b"); - assertSetContainsExactly( - fileSystemOperations.names, - "b", - "b (1)", - "b (2)" - ); - }); + await fileOperations.create("c", new Uint8Array()); + await fileOperations.move("c", "b"); + assertSetContainsExactly( + fileSystemOperations.names, + "b", + "b (1)", + "b (2)" + ); + }); - it("should deconflict renames with file extension", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations - ); + it("should deconflict renames with file extension", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); - await fileOperations.create("b.md", new Uint8Array()); - await fileOperations.create("c.md", new Uint8Array()); - await fileOperations.move("c.md", "b.md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md" - ); + await fileOperations.create("b.md", new Uint8Array()); + await fileOperations.create("c.md", new Uint8Array()); + await fileOperations.move("c.md", "b.md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md" + ); - await fileOperations.create("d.md", new Uint8Array()); - await fileOperations.move("d.md", "b.md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md", - "b (2).md" - ); + await fileOperations.create("d.md", new Uint8Array()); + await fileOperations.move("d.md", "b.md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md", + "b (2).md" + ); - await fileOperations.create("file-23.md", new Uint8Array()); - await fileOperations.create("file-23 (1).md", new Uint8Array()); - await fileOperations.move("file-23.md", "file-23 (1).md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md", - "b (2).md", - "file-23 (1).md", - "file-23 (2).md" - ); - }); + await fileOperations.create("file-23.md", new Uint8Array()); + await fileOperations.create("file-23 (1).md", new Uint8Array()); + await fileOperations.move("file-23.md", "file-23 (1).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md", + "b (2).md", + "file-23 (1).md", + "file-23 (2).md" + ); + }); - it("should deconflict renames with paths", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations - ); + it("should deconflict renames with paths", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); - await fileOperations.create("a/b.c/d", new Uint8Array()); - await fileOperations.create("a/b.c/e", new Uint8Array()); - await fileOperations.move("a/b.c/d", "a/b.c/e"); - assertSetContainsExactly( - fileSystemOperations.names, - "a/b.c/e", - "a/b.c/e (1)" - ); - }); + await fileOperations.create("a/b.c/d", new Uint8Array()); + await fileOperations.create("a/b.c/e", new Uint8Array()); + await fileOperations.move("a/b.c/d", "a/b.c/e"); + assertSetContainsExactly( + fileSystemOperations.names, + "a/b.c/e", + "a/b.c/e (1)" + ); + }); + + it("should continue deconfliction from existing number in filename", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); + + await fileOperations.create("document (5).md", new Uint8Array()); + await fileOperations.create("other.md", new Uint8Array()); + + await fileOperations.move("other.md", "document (5).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "document (5).md", + "document (6).md" + ); + + await fileOperations.create("another.md", new Uint8Array()); + await fileOperations.move("another.md", "document (5).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "document (5).md", + "document (6).md", + "document (7).md" + ); + }); + + it("should handle dotfiles correctly", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); + + await fileOperations.create(".gitignore", new Uint8Array()); + await fileOperations.create("temp", new Uint8Array()); + await fileOperations.move("temp", ".gitignore"); + assertSetContainsExactly( + fileSystemOperations.names, + ".gitignore", + ".gitignore (1)" + ); + + await fileOperations.create(".config.json", new Uint8Array()); + await fileOperations.create("temp2", new Uint8Array()); + await fileOperations.move("temp2", ".config.json"); + assertSetContainsExactly( + fileSystemOperations.names, + ".gitignore", + ".gitignore (1)", + ".config.json", + ".config (1).json" + ); + }); }); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 56ce0e51..2864bd20 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -3,250 +3,289 @@ import type { FileSystemOperations } from "./filesystem-operations"; import type { Database, RelativePath } from "../persistence/database"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; -import { isBinary, reconcile } from "reconcile-text"; +import { reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; +import { isBinary } from "../utils/is-binary"; +import type { ServerConfig } from "../services/server-config"; export class FileOperations { - private static readonly PARENTHESES_REGEX = / \((\d+)\)$/; - private readonly fs: SafeFileSystemOperations; + private static readonly PARENTHESES_REGEX = / \((?\d+)\)$/; + private readonly fs: SafeFileSystemOperations; - public constructor( - private readonly logger: Logger, - private readonly database: Database, - fs: FileSystemOperations, - private readonly nativeLineEndings = "\n" - ) { - this.fs = new SafeFileSystemOperations(fs, logger); - } + public constructor( + private readonly logger: Logger, + private readonly database: Database, + fs: FileSystemOperations, + private readonly serverConfig: ServerConfig, + private readonly nativeLineEndings = "\n" + ) { + this.fs = new SafeFileSystemOperations(fs, logger); + } - private static getParentDirAndFile( - path: RelativePath - ): [RelativePath, RelativePath] { - const pathParts = path.split("/"); - const fileName = pathParts.pop(); - if (fileName == "" || fileName == null) { - throw new Error(`Path '${path}' cannot be empty`); - } + private static getParentDirAndFile( + path: RelativePath + ): [RelativePath, RelativePath] { + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + if (fileName == null || fileName === "") { + throw new Error(`Path '${path}' cannot be empty`); + } - return [pathParts.join("/"), fileName]; - } + return [pathParts.join("/"), fileName]; + } - public async listFilesRecursively( - root: RelativePath | undefined = undefined - ): Promise { - return this.fs.listFilesRecursively(root); - } + public async listFilesRecursively( + root: RelativePath | undefined = undefined + ): Promise { + return this.fs.listFilesRecursively(root); + } - public async read(path: RelativePath): Promise { - return this.fromNativeLineEndings(await this.fs.read(path)); - } + public async read(path: RelativePath): Promise { + return this.fromNativeLineEndings(await this.fs.read(path)); + } - /** - * Create a file at the specified path. - * - * If a file with the same name already exists, it is moved before creating the new one. - * Parent directories are created if necessary. - */ - public async create( - path: RelativePath, - newContent: Uint8Array - ): Promise { - await this.ensureClearPath(path); - return this.fs.write(path, this.toNativeLineEndings(newContent)); - } + /** + * Create a file at the specified path. + * + * If a file with the same name already exists, it is moved before creating the new one. + * Parent directories are created if necessary. + */ + public async create( + path: RelativePath, + newContent: Uint8Array + ): Promise { + await this.ensureClearPath(path); + return this.fs.write(path, this.toNativeLineEndings(newContent)); + } - public async ensureClearPath(path: RelativePath): Promise { - if (await this.fs.exists(path)) { - const deconflictedPath = await this.deconflictPath(path); - this.logger.debug( - `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` - ); + public async ensureClearPath(path: RelativePath): Promise { + if (await this.fs.exists(path)) { + const deconflictedPath = await this.deconflictPath(path); + try { + this.logger.debug( + `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` + ); - this.database.move(path, deconflictedPath); - await this.fs.rename(path, deconflictedPath); - } else { - await this.createParentDirectories(path); - } - } + this.database.move(path, deconflictedPath); + await this.fs.rename(path, deconflictedPath, true); + } finally { + this.fs.unlock(deconflictedPath); + } + } else { + await this.createParentDirectories(path); + } + } - /** - * Update the file at the given path. - * - * Performs a 3-way merge before writing if the file's content differs from `expectedContent`. - * Does not recreate the file if it no longer exists, returning an empty array instead. - */ - public async write( - path: RelativePath, - expectedContent: Uint8Array, - newContent: Uint8Array - ): Promise { - if (!(await this.fs.exists(path))) { - this.logger.debug( - `The caller assumed ${path} exists, but it no longer, so we wont recreate it` - ); - return; - } + /** + * Update the file at the given path. + * + * Performs a 3-way merge before writing if the file's content differs from `expectedContent`. + * Does not recreate the file if it no longer exists, returning an empty array instead. + */ + public async write( + path: RelativePath, + expectedContent: Uint8Array, + newContent: Uint8Array + ): Promise { + if (!(await this.fs.exists(path))) { + this.logger.debug( + `The caller assumed ${path} exists, but it no longer, so we wont recreate it` + ); + return; + } - if ( - !isFileTypeMergable(path) || - isBinary(expectedContent) || - isBinary(newContent) - ) { - this.logger.debug( - `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` - ); - await this.fs.write( - path, - // `newContent` might not be binary so we still have to ensure the line endings are correct - this.toNativeLineEndings(newContent) - ); - return; - } + if ( + !isFileTypeMergable( + path, + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) || + isBinary(expectedContent) || + isBinary(newContent) + ) { + this.logger.debug( + `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` + ); + await this.fs.write( + path, + // `newContent` might not be binary so we still have to ensure the line endings are correct + this.toNativeLineEndings(newContent) + ); + return; + } - const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings - const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings + const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings + const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings - await this.fs.atomicUpdateText( - path, - ({ text, cursors }: TextWithCursors): TextWithCursors => { - this.logger.debug( - `Performing a 3-way merge for ${path} with the expected content` - ); + await this.fs.atomicUpdateText( + path, + ({ text, cursors }: TextWithCursors): TextWithCursors => { + this.logger.debug( + `Performing a 3-way merge for ${path} with the expected content` + ); - text = text.replace(this.nativeLineEndings, "\n"); - const merged = reconcile( - expectedText, - { text, cursors }, - newText - ); + text = text.replaceAll(this.nativeLineEndings, "\n"); + const merged = reconcile( + expectedText, + { text, cursors }, + newText + ); - const resultText = merged.text.replace( - "\n", - this.nativeLineEndings - ); + const resultText = merged.text.replaceAll( + "\n", + this.nativeLineEndings + ); - return { - text: resultText, - cursors: merged.cursors - }; - } - ); - } + return { + text: resultText, + cursors: merged.cursors + }; + } + ); + } - public async delete(path: RelativePath): Promise { - if (await this.exists(path)) { - await this.fs.delete(path); - await this.deletingEmptyParentDirectoriesOfDeletedFile(path); - } else { - this.logger.debug(`No need to delete '${path}', it doesn't exist`); - } - } + public async delete(path: RelativePath): Promise { + if (await this.exists(path)) { + await this.fs.delete(path); + await this.deletingEmptyParentDirectoriesOfDeletedFile(path); + } else { + this.logger.debug(`No need to delete '${path}', it doesn't exist`); + } + } - public async getFileSize(path: RelativePath): Promise { - return this.fs.getFileSize(path); - } + public async getFileSize(path: RelativePath): Promise { + return this.fs.getFileSize(path); + } - public async exists(path: RelativePath): Promise { - return this.fs.exists(path); - } + public async exists(path: RelativePath): Promise { + return this.fs.exists(path); + } - public async move( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - if (oldPath === newPath) { - return; - } + public async move( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + if (oldPath === newPath) { + return; + } - await this.ensureClearPath(newPath); + await this.ensureClearPath(newPath); - this.database.move(oldPath, newPath); - await this.fs.rename(oldPath, newPath); - await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - } + this.database.move(oldPath, newPath); + await this.fs.rename(oldPath, newPath); + await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); + } - private async deletingEmptyParentDirectoriesOfDeletedFile( - path: RelativePath - ): Promise { - let directory = path; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - [directory] = FileOperations.getParentDirAndFile(directory); - if (directory.length === 0) { - break; - } + public reset(): void { + this.fs.reset(); + } - const remainingContent = - await this.fs.listFilesRecursively(directory); - if (remainingContent.length === 0) { - this.logger.debug( - `Folder (${directory}) is now empty, deleting` - ); - await this.fs.delete(directory); - } else { - break; - } - } - } + private async deletingEmptyParentDirectoriesOfDeletedFile( + path: RelativePath + ): Promise { + let directory = path; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + [directory] = FileOperations.getParentDirAndFile(directory); + if (directory.length === 0) { + break; + } - private fromNativeLineEndings(content: Uint8Array): Uint8Array { - if (isBinary(content)) { - return content; - } + const remainingContent = + await this.fs.listFilesRecursively(directory); + if (remainingContent.length === 0) { + this.logger.debug( + `Folder (${directory}) is now empty, deleting` + ); + await this.fs.delete(directory); + } else { + break; + } + } + } - const decoder = new TextDecoder("utf-8"); - let text = decoder.decode(content); - text = text.replace(this.nativeLineEndings, "\n"); - return new TextEncoder().encode(text); - } + private fromNativeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } - private toNativeLineEndings(content: Uint8Array): Uint8Array { - if (isBinary(content)) { - return content; - } + const decoder = new TextDecoder("utf-8"); + let text = decoder.decode(content); + text = text.replaceAll(this.nativeLineEndings, "\n"); + return new TextEncoder().encode(text); + } - const decoder = new TextDecoder("utf-8"); - let text = decoder.decode(content); - text = text.replace("\n", this.nativeLineEndings); - return new TextEncoder().encode(text); - } + private toNativeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } - private async createParentDirectories(path: string): Promise { - const components = path.split("/"); - if (components.length === 1) { - return; - } - for (let i = 1; i < components.length; i++) { - const parentDir = components.slice(0, i).join("/"); - if (!(await this.fs.exists(parentDir))) { - await this.fs.createDirectory(parentDir); - } - } - } + const decoder = new TextDecoder("utf-8"); + let text = decoder.decode(content); + text = text.replaceAll("\n", this.nativeLineEndings); + return new TextEncoder().encode(text); + } - private async deconflictPath(path: RelativePath): Promise { - // eslint-disable-next-line prefer-const - let [directory, fileName] = FileOperations.getParentDirAndFile(path); + private async createParentDirectories(path: string): Promise { + const components = path.split("/"); + if (components.length === 1) { + return; + } + for (let i = 1; i < components.length; i++) { + const parentDir = components.slice(0, i).join("/"); + if (!(await this.fs.exists(parentDir))) { + await this.fs.createDirectory(parentDir); + } + } + } - if (directory) { - directory += "/"; - } + /** + * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. + * The returned path has a lock acquired on it; it must be released by the caller when no longer needed. + * + * @param path The starting path to deconflict + * @returns a non-existent path with a lock acquired on it + */ + private async deconflictPath(path: RelativePath): Promise { + // eslint-disable-next-line prefer-const + let [directory, fileName] = FileOperations.getParentDirAndFile(path); - const nameParts = fileName.split("."); - const extension = - nameParts.length > 1 ? "." + nameParts[nameParts.length - 1] : ""; - let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; - let currentCount = Number.parseInt( - FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.[0] ?? "0" - ); - stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); + if (directory) { + directory += "/"; + } - let newName = path; - do { - currentCount++; - newName = `${directory}${stem} (${currentCount})${extension}`; - } while (await this.fs.exists(newName)); + const nameParts = fileName.split("."); + // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json" + const isDotfile = fileName.startsWith(".") && nameParts[0] === ""; + const extension = + nameParts.length > 1 && !(isDotfile && nameParts.length === 2) + ? "." + nameParts[nameParts.length - 1] + : ""; + let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; + let currentCount = Number.parseInt( + FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0" + ); + stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); - return newName; - } + let newName = path; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + currentCount++; + newName = `${directory}${stem} (${currentCount})${extension}`; + + // Avoid multiple deconflictPath calls returning the same path + if (this.fs.tryLock(newName)) { + const newDocument = + this.database.getLatestDocumentByRelativePath(newName); + if ( + newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally + (await this.fs.exists(newName, true)) + ) { + this.fs.unlock(newName); + } else { + return newName; + } + } + } + } } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index 9c7a8366..36dddfe6 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -3,35 +3,35 @@ import type { RelativePath } from "../persistence/database"; import type { TextWithCursors } from "reconcile-text"; export interface FileSystemOperations { - // List all files under root that should be synced. If root is undefined, return every file. - listFilesRecursively: ( - root: RelativePath | undefined - ) => Promise; + // List all files under root that should be synced. If root is undefined, return every file. + listFilesRecursively: ( + root: RelativePath | undefined + ) => Promise; - // Read the content of a file. - read: (path: RelativePath) => Promise; + // Read the content of a file. + read: (path: RelativePath) => Promise; - // Create or overwrite a file with the given content. - write: (path: RelativePath, content: Uint8Array) => Promise; + // Create or overwrite a file with the given content. + write: (path: RelativePath, content: Uint8Array) => Promise; - // Atomically update the content of a text file. - atomicUpdateText: ( - path: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ) => Promise; + // Atomically update the content of a text file. + atomicUpdateText: ( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ) => Promise; - // Get the size of a file in bytes. - getFileSize: (path: RelativePath) => Promise; + // Get the size of a file in bytes. + getFileSize: (path: RelativePath) => Promise; - // Check if a file exists. - exists: (path: RelativePath) => Promise; + // Check if a file exists. + exists: (path: RelativePath) => Promise; - // Create a directory at the specified path. All parent directories must already exist. - createDirectory: (path: RelativePath) => Promise; + // Create a directory at the specified path. All parent directories must already exist. + createDirectory: (path: RelativePath) => Promise; - // Delete a file. It is expected that the path points to an existing file. - delete: (path: RelativePath) => Promise; + // Delete a file. It is expected that the path points to an existing file. + delete: (path: RelativePath) => Promise; - // Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist. - rename: (oldPath: RelativePath, newPath: RelativePath) => Promise; + // Rename a file. It is expected that the oldPath points to an existing file and the newPath does not exist. + rename: (oldPath: RelativePath, newPath: RelativePath) => Promise; } diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 2c865c9f..904bf805 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -1,7 +1,7 @@ import type { RelativePath } from "../persistence/database"; import type { FileSystemOperations } from "./filesystem-operations"; import type { Logger } from "../tracing/logger"; -import { Locks } from "../utils/locks"; +import { Locks } from "../utils/data-structures/locks"; import { FileNotFoundError } from "./file-not-found-error"; import type { TextWithCursors } from "reconcile-text"; @@ -11,129 +11,160 @@ import type { TextWithCursors } from "reconcile-text"; * single request in-flight for any one file through the use of locks. */ export class SafeFileSystemOperations implements FileSystemOperations { - private readonly locks: Locks; + private readonly locks: Locks; - public constructor( - private readonly fs: FileSystemOperations, - private readonly logger: Logger - ) { - this.locks = new Locks(logger); - } + public constructor( + private readonly fs: FileSystemOperations, + private readonly logger: Logger + ) { + this.locks = new Locks(logger); + } - public async listFilesRecursively( - root: RelativePath | undefined - ): Promise { - this.logger.debug("Listing all files"); - const result = await this.fs.listFilesRecursively(root); - this.logger.debug(`Listed ${result.length} files`); - return result; - } + public async listFilesRecursively( + root: RelativePath | undefined + ): Promise { + this.logger.debug("Listing all files"); + const result = await this.fs.listFilesRecursively(root); + this.logger.debug(`Listed ${result.length} files`); + return result; + } - public async read(path: RelativePath): Promise { - this.logger.debug(`Reading file '${path}'`); - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => this.fs.read(path)), - "read" - ); - } + public async read(path: RelativePath): Promise { + this.logger.debug(`Reading file '${path}'`); + return this.safeOperation( + path, + async () => + this.locks.withLock(path, async () => this.fs.read(path)), + "read" + ); + } - public async write(path: RelativePath, content: Uint8Array): Promise { - this.logger.debug(`Writing to file '${path}'`); - return this.locks.withLock(path, async () => - this.fs.write(path, content) - ); - } + public async write(path: RelativePath, content: Uint8Array): Promise { + this.logger.debug(`Writing to file '${path}'`); + return this.locks.withLock(path, async () => + this.fs.write(path, content) + ); + } - public async atomicUpdateText( - path: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - this.logger.debug(`Atomically updating file '${path}'`); - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => - this.fs.atomicUpdateText(path, updater) - ), - "atomicUpdateText" - ); - } + public async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + this.logger.debug(`Atomically updating file '${path}'`); + return this.safeOperation( + path, + async () => + this.locks.withLock(path, async () => + this.fs.atomicUpdateText(path, updater) + ), + "atomicUpdateText" + ); + } - public async getFileSize(path: RelativePath): Promise { - // Logging this would be too noisy - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => - this.fs.getFileSize(path) - ), - "getFileSize" - ); - } + public async getFileSize(path: RelativePath): Promise { + // Logging this would be too noisy + return this.safeOperation( + path, + async () => + this.locks.withLock(path, async () => + this.fs.getFileSize(path) + ), + "getFileSize" + ); + } - public async exists(path: RelativePath): Promise { - this.logger.debug(`Checking if file '${path}' exists`); - return this.locks.withLock(path, async () => this.fs.exists(path)); - } + public async exists( + path: RelativePath, + skipLock = false + ): Promise { + this.logger.debug(`Checking if file '${path}' exists`); + if (skipLock) { + return this.fs.exists(path); + } else { + return this.locks.withLock(path, async () => this.fs.exists(path)); + } + } - public async createDirectory(path: RelativePath): Promise { - this.logger.debug(`Creating directory '${path}'`); - return this.locks.withLock(path, async () => - this.fs.createDirectory(path) - ); - } + public async createDirectory(path: RelativePath): Promise { + this.logger.debug(`Creating directory '${path}'`); + return this.locks.withLock(path, async () => + this.fs.createDirectory(path) + ); + } - public async delete(path: RelativePath): Promise { - this.logger.debug(`Deleting file '${path}'`); - return this.locks.withLock(path, async () => this.fs.delete(path)); - } + public async delete(path: RelativePath): Promise { + this.logger.debug(`Deleting file '${path}'`); + return this.locks.withLock(path, async () => this.fs.delete(path)); + } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`); - return this.safeOperation( - oldPath, - async () => - this.locks.withLock([oldPath, newPath], async () => - this.fs.rename(oldPath, newPath) - ), - "rename" - ); - } + public async rename( + oldPath: RelativePath, + newPath: RelativePath, + skipLock = false + ): Promise { + this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`); + return this.safeOperation( + oldPath, + async () => { + if (skipLock) { + return this.fs.rename(oldPath, newPath); + } else { + return this.locks.withLock([oldPath, newPath], async () => + this.fs.rename(oldPath, newPath) + ); + } + }, + "rename" + ); + } - /** - * Decorate an operation to ensure that the file exists before running it. - * If the operation fails, it will check if the file still exists and throw - * a FileNotFoundError if it doesn't. - */ - private async safeOperation( - path: RelativePath, - operation: () => Promise, - operationName: string - ): Promise { - if (!(await this.fs.exists(path))) { - throw new FileNotFoundError( - `File '${path}' not found before trying to ${operationName}` - ); - } + public tryLock(path: RelativePath): boolean { + return this.locks.tryLock(path); + } - try { - return await operation(); - } catch (error) { - // Without locking the file, this isn't atomic, however, it's good enough in practice. - // This will only break if the file exists, gets deleted and then immediately - // recreated while `operation` is running. - if (await this.fs.exists(path)) { - throw error; - } else { - throw new FileNotFoundError( - `File '${path}' not found when trying to ${operationName}` - ); - } - } - } + public async waitForLock(path: RelativePath): Promise { + return this.locks.waitForLock(path); + } + + public unlock(path: RelativePath): void { + this.locks.unlock(path); + } + + public reset(): void { + this.locks.reset(); + } + + /** + * Decorate an operation to ensure that the file exists before running it. + * If the operation fails, it will check if the file still exists and throw + * a FileNotFoundError if it doesn't. + */ + private async safeOperation( + path: RelativePath, + operation: () => Promise, + operationName: string + ): Promise { + if (!(await this.fs.exists(path))) { + throw new FileNotFoundError( + `File not found before trying to ${operationName}`, + path + ); + } + + try { + return await operation(); + } catch (error) { + // Without locking the file, this isn't atomic, however, it's good enough in practice. + // This will only break if the file exists, gets deleted and then immediately + // recreated while `operation` is running. + if (await this.fs.exists(path)) { + throw error; + } else { + throw new FileNotFoundError( + `File not found when trying to ${operationName}`, + path + ); + } + } + } } diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index a73f63dd..cfcc5071 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -1,20 +1,22 @@ -import { logToConsole } from "./debugging/log-to-console"; -import { slowFetchFactory } from "./debugging/slow-fetch-factory"; -import { slowWebSocketFactory } from "./debugging/slow-web-socket-factory"; +import { awaitAll } from "./utils/await-all"; +import { logToConsole } from "./utils/debugging/log-to-console"; +import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory"; +import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"; import { getRandomColor } from "./utils/get-random-color"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; +import { removeFromArray } from "./utils/remove-from-array"; export { - SyncType, - SyncStatus, - type HistoryStats, - type HistoryEntry, - type SyncDetails, - type SyncCreateDetails, - type SyncUpdateDetails, - type SyncMovedDetails, - type SyncDeleteDetails + SyncType, + SyncStatus, + type HistoryStats, + type HistoryEntry, + type SyncDetails, + type SyncCreateDetails, + type SyncUpdateDetails, + type SyncMovedDetails, + type SyncDeleteDetails } from "./tracing/sync-history"; export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; @@ -25,18 +27,23 @@ export type { PersistenceProvider } from "./persistence/persistence"; export type { CursorSpan } from "./services/types/CursorSpan"; export type { ClientCursors } from "./services/types/ClientCursors"; export type { NetworkConnectionStatus } from "./types/network-connection-status"; +export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error"; +export type { AuthenticationError } from "./services/authentication-error"; export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; export { DocumentSyncStatus } from "./types/document-sync-status"; export { SyncClient } from "./sync-client"; +export type { TextWithCursors, CursorPosition } from "reconcile-text"; export const debugging = { - slowFetchFactory, - slowWebSocketFactory, - logToConsole + slowFetchFactory, + slowWebSocketFactory, + logToConsole }; export const utils = { - getRandomColor, - positionToLineAndColumn, - lineAndColumnToPosition + getRandomColor, + positionToLineAndColumn, + lineAndColumnToPosition, + awaitAll, + removeFromArray }; diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 9425c629..86b2845c 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -1,29 +1,31 @@ import type { Logger } from "../tracing/logger"; import { EMPTY_HASH } from "../utils/hash"; -import { CoveredValues } from "../utils/min-covered"; +import { CoveredValues } from "../utils/data-structures/min-covered"; +import { awaitAll } from "../utils/await-all"; +import { removeFromArray } from "../utils/remove-from-array"; export type VaultUpdateId = number; export type DocumentId = string; export type RelativePath = string; export interface DocumentMetadata { - parentVersionId: VaultUpdateId; - hash: string; - remoteRelativePath?: RelativePath; + parentVersionId: VaultUpdateId; + hash: string; + remoteRelativePath?: RelativePath; } export interface StoredDocumentMetadata { - relativePath: RelativePath; - documentId: DocumentId; - parentVersionId: VaultUpdateId; - remoteRelativePath?: RelativePath; - hash: string; + relativePath: RelativePath; + documentId: DocumentId; + parentVersionId: VaultUpdateId; + remoteRelativePath?: RelativePath; + hash: string; } export interface StoredDatabase { - documents: StoredDocumentMetadata[]; - lastSeenUpdateId: VaultUpdateId | undefined; - hasInitialSyncCompleted: boolean; + documents: StoredDocumentMetadata[]; + lastSeenUpdateId: VaultUpdateId | undefined; + hasInitialSyncCompleted: boolean; } /** @@ -33,328 +35,340 @@ export interface StoredDatabase { * state of the document on disk based on the update events we have seen. */ export interface DocumentRecord { - relativePath: RelativePath; - documentId: DocumentId; - metadata: DocumentMetadata | undefined; - isDeleted: boolean; - updates: Promise[]; - parallelVersion: number; + relativePath: RelativePath; + documentId: DocumentId; + metadata: DocumentMetadata | undefined; + isDeleted: boolean; + updates: Promise[]; + parallelVersion: number; } export class Database { - private documents: DocumentRecord[]; - private lastSeenUpdateIds: CoveredValues; - private hasInitialSyncCompleted: boolean; + private documents: DocumentRecord[]; + private lastSeenUpdateIds: CoveredValues; + private hasInitialSyncCompleted: boolean; - public constructor( - private readonly logger: Logger, - initialState: Partial | undefined, - private readonly saveData: (data: StoredDatabase) => Promise - ) { - initialState ??= {}; + public constructor( + private readonly logger: Logger, + initialState: Partial | undefined, + private readonly saveData: (data: StoredDatabase) => Promise + ) { + initialState ??= {}; - this.documents = - initialState.documents?.map( - ({ relativePath, documentId, ...metadata }) => ({ - relativePath, - documentId, - metadata, - isDeleted: false, - updates: [], - parallelVersion: 0 - }) - ) ?? []; + this.documents = + initialState.documents?.map( + ({ relativePath, documentId, ...metadata }) => ({ + relativePath, + documentId, + metadata, + isDeleted: false, + updates: [], + parallelVersion: 0 + }) + ) ?? []; - this.ensureConsistency(); - this.logger.debug(`Loaded ${this.documents.length} documents`); + this.ensureConsistency(); + this.logger.debug(`Loaded ${this.documents.length} documents`); - const { lastSeenUpdateId } = initialState; - this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`); - this.lastSeenUpdateIds = new CoveredValues( - Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1 - ); + const { lastSeenUpdateId } = initialState; + this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`); + this.lastSeenUpdateIds = new CoveredValues( + Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1 + ); - this.hasInitialSyncCompleted = - initialState.hasInitialSyncCompleted ?? false; - this.logger.debug( - `Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}` - ); - } + this.documents.forEach((doc) => { + this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId); + }); - public get length(): number { - return this.documents.length; - } + this.hasInitialSyncCompleted = + initialState.hasInitialSyncCompleted ?? false; + this.logger.debug( + `Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}` + ); + } - public get resolvedDocuments(): DocumentRecord[] { - const paths = new Map(); - this.documents - .filter(({ metadata }) => metadata !== undefined) - .forEach((record) => - paths.set(record.relativePath, [ - record, - ...(paths.get(record.relativePath) ?? []) - ]) - ); + public get length(): number { + return this.documents.length; + } - return Array.from(paths.values()).map((records) => { - records.sort( - (a, b) => b.parallelVersion - a.parallelVersion // descending - ); + public get resolvedDocuments(): DocumentRecord[] { + const paths = new Map(); + this.documents + // eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item + .filter(({ metadata }) => metadata !== undefined) + .forEach((record) => + paths.set(record.relativePath, [ + record, + ...(paths.get(record.relativePath) ?? []) + ]) + ); - if ( - records.length > 1 && - records.some((current, i) => - i === 0 - ? false - : records[i - 1].parallelVersion === - current.parallelVersion - ) - ) { - throw new Error( - `Multiple documents with the same parallel version and path at ${records[0].relativePath}` - ); - } - return records[0]; - }); - } + return Array.from(paths.values()).map((records) => { + records.sort( + (a, b) => b.parallelVersion - a.parallelVersion // descending + ); - public updateDocumentMetadata( - metadata: { - parentVersionId: VaultUpdateId; - hash: string; - remoteRelativePath: RelativePath; - }, - toUpdate: DocumentRecord - ): void { - if (!this.documents.includes(toUpdate)) { - throw new Error("Document not found in database"); - } + if ( + records.length > 1 && + records.some((current, i) => + i === 0 + ? false + : records[i - 1].parallelVersion === + current.parallelVersion + ) + ) { + throw new Error( + `Multiple documents with the same parallel version and path at ${records[0].relativePath}` + ); + } + return records[0]; + }); + } - toUpdate.metadata = metadata; + public updateDocumentMetadata( + metadata: { + parentVersionId: VaultUpdateId; + hash: string; + remoteRelativePath: RelativePath; + }, + toUpdate: DocumentRecord + ): void { + if (!this.documents.includes(toUpdate)) { + throw new Error("Document not found in database"); + } - this.save(); - } + toUpdate.metadata = metadata; - public removeDocumentPromise(promise: Promise): void { - const entry = this.documents.find(({ updates }) => - updates.includes(promise) - ); + this.saveInTheBackground(); + } - if (entry === undefined) { - // This method should be idempotent and tolerant of - // stragglers calling it after the databse has been reset. - return; - } + public removeDocumentPromise(promise: Promise): void { + const entry = this.documents.find(({ updates }) => + updates.includes(promise) + ); - entry.updates = entry.updates.filter((update) => update !== promise); - // No need to save as Promises don't get serialized - } + if (entry === undefined) { + // This method should be idempotent and tolerant of + // stragglers calling it after the databse has been reset. + return; + } - public removeDocument(find: DocumentRecord): void { - this.documents = this.documents.filter((document) => document !== find); - this.save(); - } + removeFromArray(entry.updates, promise); + // No need to save as Promises don't get serialized + } - public getLatestDocumentByRelativePath( - find: RelativePath - ): DocumentRecord | undefined { - const candidates = this.documents.filter( - ({ relativePath }) => relativePath === find - ); - candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending - return candidates[0]; - } + public removeDocument(find: DocumentRecord): void { + removeFromArray(this.documents, find); + this.saveInTheBackground(); + } - public async getResolvedDocumentByRelativePath( - relativePath: RelativePath, - promise: Promise - ): Promise { - const entry = this.getLatestDocumentByRelativePath(relativePath); + public getLatestDocumentByRelativePath( + find: RelativePath + ): DocumentRecord | undefined { + const candidates = this.documents.filter( + ({ relativePath }) => relativePath === find + ); + candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending + return candidates[0]; + } - if (entry === undefined) { - throw new Error( - `Document not found by relative path: ${relativePath}, ${JSON.stringify( - this.documents, - null, - 2 - )}` - ); - } + public async getResolvedDocumentByRelativePath( + relativePath: RelativePath, + promise: Promise + ): Promise { + const entry = this.getLatestDocumentByRelativePath(relativePath); - const currentPromises = entry.updates; - entry.updates = [...currentPromises, promise]; - await Promise.all(currentPromises); + if (entry === undefined) { + throw new Error( + `Document not found by relative path: ${relativePath}, ${JSON.stringify( + this.documents, + null, + 2 + )}` + ); + } - return entry; - } + const currentPromises = entry.updates; + entry.updates = [...currentPromises, promise]; + await awaitAll(currentPromises); - public createNewPendingDocument( - documentId: DocumentId, - relativePath: RelativePath, - promise: Promise - ): DocumentRecord { - const previousEntry = - this.getLatestDocumentByRelativePath(relativePath); + return entry; + } - const entry = { - relativePath, - documentId, - metadata: undefined, - isDeleted: false, - updates: [promise], - parallelVersion: - previousEntry?.parallelVersion === undefined - ? 0 - : previousEntry.parallelVersion + 1 - }; + public createNewPendingDocument( + documentId: DocumentId, + relativePath: RelativePath, + promise: Promise + ): DocumentRecord { + this.logger.debug( + `Creating new pending document: ${relativePath} (${documentId})` + ); + const previousEntry = + this.getLatestDocumentByRelativePath(relativePath); - this.documents.push(entry); - this.save(); + const entry = { + relativePath, + documentId, + metadata: undefined, + isDeleted: false, + updates: [promise], + parallelVersion: + previousEntry?.parallelVersion === undefined + ? 0 + : previousEntry.parallelVersion + 1 + }; - return entry; - } + this.documents.push(entry); + this.saveInTheBackground(); - public createNewEmptyDocument( - documentId: DocumentId, - parentVersionId: VaultUpdateId, - relativePath: RelativePath - ): DocumentRecord { - const entry = { - relativePath, - documentId, - metadata: { - parentVersionId, - hash: EMPTY_HASH, - remoteRelativePath: relativePath - }, - isDeleted: false, - updates: [], - parallelVersion: 0 - }; + return entry; + } - this.documents.push(entry); - this.save(); + public createNewEmptyDocument( + documentId: DocumentId, + parentVersionId: VaultUpdateId, + relativePath: RelativePath + ): DocumentRecord { + const entry = { + relativePath, + documentId, + metadata: { + parentVersionId, + hash: EMPTY_HASH, + remoteRelativePath: relativePath + }, + isDeleted: false, + updates: [], + parallelVersion: 0 + }; - return entry; - } + this.documents.push(entry); + this.saveInTheBackground(); - public getDocumentByDocumentId( - find: DocumentId - ): DocumentRecord | undefined { - return this.documents.find(({ documentId }) => documentId === find); - } + return entry; + } - public move( - oldRelativePath: RelativePath, - newRelativePath: RelativePath - ): void { - const oldDocument = - this.getLatestDocumentByRelativePath(oldRelativePath); + public getDocumentByDocumentId( + find: DocumentId + ): DocumentRecord | undefined { + return this.documents.find(({ documentId }) => documentId === find); + } - if (oldDocument === undefined) { - return; - } + public move( + oldRelativePath: RelativePath, + newRelativePath: RelativePath + ): void { + const oldDocument = + this.getLatestDocumentByRelativePath(oldRelativePath); - const newDocument = - this.getLatestDocumentByRelativePath(newRelativePath); - if (newDocument?.isDeleted === false) { - throw new Error( - `Document already exists at new location: ${newRelativePath}` - ); - } + if (oldDocument === undefined) { + return; + } - oldDocument.relativePath = newRelativePath; - // We're in a strange state where the target of the move has just got deleted, - // however, its metadata might already have a bunch of updates queued up for - // the document at the new location. We need to keep these updates. - oldDocument.parallelVersion = - newDocument !== undefined ? newDocument.parallelVersion + 1 : 0; + const newDocument = + this.getLatestDocumentByRelativePath(newRelativePath); + if (newDocument?.isDeleted === false) { + throw new Error( + `Document already exists at new location: ${newRelativePath}` + ); + } - this.save(); - } + oldDocument.relativePath = newRelativePath; + // We're in a strange state where the target of the move has just got deleted, + // however, its metadata might already have a bunch of updates queued up for + // the document at the new location. We need to keep these updates. + oldDocument.parallelVersion = + newDocument !== undefined ? newDocument.parallelVersion + 1 : 0; - public delete(relativePath: RelativePath): void { - const candidate = this.getLatestDocumentByRelativePath(relativePath); - if (candidate === undefined) { - throw new Error( - `Document not found by relative path: ${relativePath}` - ); - } - candidate.isDeleted = true; - } + this.saveInTheBackground(); + } - public getHasInitialSyncCompleted(): boolean { - return this.hasInitialSyncCompleted; - } + public delete(relativePath: RelativePath): void { + const candidate = this.getLatestDocumentByRelativePath(relativePath); + if (candidate === undefined) { + throw new Error( + `Document not found by relative path: ${relativePath}` + ); + } + candidate.isDeleted = true; + } - public setHasInitialSyncCompleted(value: boolean): void { - this.hasInitialSyncCompleted = value; - this.save(); - } + public getHasInitialSyncCompleted(): boolean { + return this.hasInitialSyncCompleted; + } - public getLastSeenUpdateId(): VaultUpdateId { - return this.lastSeenUpdateIds.min; - } + public setHasInitialSyncCompleted(value: boolean): void { + this.hasInitialSyncCompleted = value; + this.saveInTheBackground(); + } - public addSeenUpdateId(value: number): void { - const previousMin = this.lastSeenUpdateIds.min; - this.lastSeenUpdateIds.add(value); - if (previousMin !== this.lastSeenUpdateIds.min) { - this.save(); - } - } + public getLastSeenUpdateId(): VaultUpdateId { + return this.lastSeenUpdateIds.min; + } - public setLastSeenUpdateId(value: number): void { - this.lastSeenUpdateIds.min = value; - this.save(); - } + public addSeenUpdateId(value: number): void { + const previousMin = this.lastSeenUpdateIds.min; + this.lastSeenUpdateIds.add(value); + if (previousMin !== this.lastSeenUpdateIds.min) { + this.saveInTheBackground(); + } + } - public reset(): void { - this.documents = []; - this.lastSeenUpdateIds = new CoveredValues( - 0 // the first updateId will be 1 which is the first integer after -1 - ); - this.hasInitialSyncCompleted = false; - this.save(); - } + public setLastSeenUpdateId(value: number): void { + this.lastSeenUpdateIds.min = value; + this.saveInTheBackground(); + } - private save(): void { - this.ensureConsistency(); - void this.saveData({ - documents: this.resolvedDocuments.map( - ({ relativePath, documentId, metadata }) => ({ - documentId, - relativePath, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...metadata! // `resolvedDocuments` only returns docs with metadata set - }) - ), - lastSeenUpdateId: this.lastSeenUpdateIds.min, - hasInitialSyncCompleted: this.hasInitialSyncCompleted - }).catch((error: unknown) => { - this.logger.error(`Error saving data: ${error}`); - }); - } + public reset(): void { + this.documents = []; + this.lastSeenUpdateIds = new CoveredValues( + 0 // the first updateId will be 1 which is the first integer after -1 + ); + this.hasInitialSyncCompleted = false; + this.saveInTheBackground(); + } - private ensureConsistency(): void { - const idToPath = new Map(); + public async save(): Promise { + return this.saveData({ + documents: this.resolvedDocuments.map( + ({ relativePath, documentId, metadata }) => ({ + documentId, + relativePath, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...metadata! // `resolvedDocuments` only returns docs with metadata set + }) + ), + lastSeenUpdateId: this.lastSeenUpdateIds.min, + hasInitialSyncCompleted: this.hasInitialSyncCompleted + }); + } - this.resolvedDocuments.forEach(({ relativePath, documentId }) => { - idToPath.set(documentId, [ - ...(idToPath.get(documentId) ?? []), - relativePath - ]); - }); + private ensureConsistency(): void { + const idToPath = new Map(); - const duplicates = Array.from(idToPath.entries()) - .filter(([_, paths]) => paths.length > 1) - .map(([id, paths]) => `${id} (${paths.join(", ")})`); + this.resolvedDocuments.forEach(({ relativePath, documentId }) => { + idToPath.set(documentId, [ + ...(idToPath.get(documentId) ?? []), + relativePath + ]); + }); - if (duplicates.length > 0) { - throw new Error( - "Document IDs are not unique, found duplicates: " + - duplicates.join("; ") - ); - } - } + const duplicates = Array.from(idToPath.entries()) + .filter(([_, paths]) => paths.length > 1) + .map(([id, paths]) => `${id} (${paths.join(", ")})`); + + if (duplicates.length > 0) { + throw new Error( + "Document IDs are not unique, found duplicates: " + + duplicates.join("; ") + ); + } + } + + private saveInTheBackground(): void { + this.ensureConsistency(); + void this.save().catch((error: unknown) => { + this.logger.error(`Error saving data: ${error}`); + }); + } } diff --git a/frontend/sync-client/src/persistence/persistence.ts b/frontend/sync-client/src/persistence/persistence.ts index 706ae6ff..d226e611 100644 --- a/frontend/sync-client/src/persistence/persistence.ts +++ b/frontend/sync-client/src/persistence/persistence.ts @@ -1,4 +1,4 @@ export interface PersistenceProvider { - load: () => Promise; - save: (data: T) => Promise; + load: () => Promise; + save: (data: T) => Promise; } diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index b0aff937..d78170e6 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -1,84 +1,94 @@ import type { Logger } from "../tracing/logger"; +import { Lock } from "../utils/data-structures/locks"; +import { EventListeners } from "../utils/data-structures/event-listeners"; export interface SyncSettings { - remoteUri: string; - token: string; - vaultName: string; - syncConcurrency: number; - isSyncEnabled: boolean; - maxFileSizeMB: number; - ignorePatterns: string[]; - webSocketRetryIntervalMs: number; + remoteUri: string; + token: string; + vaultName: string; + syncConcurrency: number; + isSyncEnabled: boolean; + maxFileSizeMB: number; + ignorePatterns: string[]; + webSocketRetryIntervalMs: number; + diffCacheSizeMB: number; + enableTelemetry: boolean; + networkRetryIntervalMs: number; + minimumSaveIntervalMs: number; } export const DEFAULT_SETTINGS: SyncSettings = { - remoteUri: "", - token: "", - vaultName: "default", - syncConcurrency: 1, - isSyncEnabled: false, - maxFileSizeMB: 10, - ignorePatterns: [], - webSocketRetryIntervalMs: 3500 + remoteUri: "", + token: "", + vaultName: "default", + syncConcurrency: 1, + isSyncEnabled: false, + maxFileSizeMB: 10, + ignorePatterns: [], + webSocketRetryIntervalMs: 3500, + diffCacheSizeMB: 4, + enableTelemetry: false, + networkRetryIntervalMs: 1000, + minimumSaveIntervalMs: 1000 }; export class Settings { - private settings: SyncSettings; + public readonly onSettingsChanged = new EventListeners< + (newSettings: SyncSettings, oldSettings: SyncSettings) => unknown + >(); - private readonly onSettingsChangeHandlers: (( - newSettings: SyncSettings, - oldSettings: SyncSettings - ) => unknown)[] = []; + private settings: SyncSettings; + private readonly lock: Lock = new Lock(); - public constructor( - private readonly logger: Logger, - initialState: Partial | undefined, - private readonly saveData: (data: SyncSettings) => Promise - ) { - this.settings = { - ...DEFAULT_SETTINGS, - ...(initialState ?? {}) - }; + public constructor( + private readonly logger: Logger, + initialState: Partial | undefined, + private readonly saveData: (data: SyncSettings) => Promise + ) { + this.settings = { + ...DEFAULT_SETTINGS, + ...(initialState ?? {}) + }; - this.logger.debug( - `Loaded settings: ${JSON.stringify(this.settings, null, 2)}` - ); - } + this.logger.debug( + `Loaded settings: ${JSON.stringify(this.settings, null, 2)}` + ); + } - public getSettings(): SyncSettings { - return this.settings; - } + public getSettings(): SyncSettings { + return this.settings; + } - public addOnSettingsChangeListener( - handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown - ): void { - this.onSettingsChangeHandlers.push(handler); - } + public async setSetting( + key: T, + value: SyncSettings[T] + ): Promise { + await this.setSettings({ + [key]: value + }); + } - public async setSetting( - key: T, - value: SyncSettings[T] - ): Promise { - this.logger.debug(`Setting '${key}' to '${value}'`); - await this.setSettings({ - [key]: value - }); - } + public async setSettings(value: Partial): Promise { + await this.lock.withLock(async () => { + this.logger.debug( + `Updating settings with: ${JSON.stringify(value)}` + ); + const oldSettings = this.settings; + this.settings = { + ...this.settings, + ...value + }; - public async setSettings(value: Partial): Promise { - const oldSettings = this.settings; - this.settings = { - ...this.settings, - ...value - }; + await this.onSettingsChanged.triggerAsync( + this.settings, + oldSettings + ); - this.onSettingsChangeHandlers.forEach((handler) => { - handler(this.settings, oldSettings); - }); - await this.save(); - } + await this.save(); + }); + } - private async save(): Promise { - await this.saveData(this.settings); - } + private async save(): Promise { + await this.saveData(this.settings); + } } diff --git a/frontend/sync-client/src/services/authentication-error.ts b/frontend/sync-client/src/services/authentication-error.ts new file mode 100644 index 00000000..6be4af24 --- /dev/null +++ b/frontend/sync-client/src/services/authentication-error.ts @@ -0,0 +1,6 @@ +export class AuthenticationError extends Error { + public constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } +} diff --git a/frontend/sync-client/src/services/connection-status.ts b/frontend/sync-client/src/services/connection-status.ts deleted file mode 100644 index 18f53a0d..00000000 --- a/frontend/sync-client/src/services/connection-status.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Settings } from "../persistence/settings"; -import type { Logger } from "../tracing/logger"; -import { createPromise } from "../utils/create-promise"; -import { SyncResetError } from "./sync-reset-error"; - -export class ConnectionStatus { - private static readonly UNTIL_RESOLUTION = Symbol(); - private canFetch: boolean; - private until: Promise; - private resolveUntil: (result: symbol) => unknown; - private rejectUntil: (reason: unknown) => unknown; - - public constructor( - settings: Settings, - private readonly logger: Logger - ) { - this.canFetch = settings.getSettings().isSyncEnabled; - - [this.until, this.resolveUntil, this.rejectUntil] = - createPromise(); - - settings.addOnSettingsChangeListener((newSettings, oldSettings) => { - if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { - this.canFetch = newSettings.isSyncEnabled; - this.resolveUntil(ConnectionStatus.UNTIL_RESOLUTION); - [this.until, this.resolveUntil, this.rejectUntil] = - createPromise(); - } - }); - } - - private static getUrlFromInput(input: RequestInfo | URL): string { - if (input instanceof URL) { - return input.href; - } - if (typeof input === "string") { - return input; - } - return input.url; - } - - public startReset(): void { - this.rejectUntil(new SyncResetError()); - } - - public finishReset(): void { - [this.until, this.resolveUntil, this.rejectUntil] = createPromise(); - } - - public getFetchImplementation( - logger: Logger, - fetch: typeof globalThis.fetch = globalThis.fetch - ): typeof globalThis.fetch { - return async ( - input: RequestInfo | URL, - init?: RequestInit - ): Promise => { - while (!this.canFetch) { - await this.until; - } - - try { - // https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21 - const _input = - typeof Request !== "undefined" && input instanceof Request - ? input.clone() - : input; - - const fetchPromise = fetch(_input, init); - - // We only want to catch rejections from `this.until` - let result: symbol | Response | undefined = undefined; - do { - result = await Promise.race([this.until, fetchPromise]); - } while (result === ConnectionStatus.UNTIL_RESOLUTION); - - const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - - if (!fetchResult.ok) { - this.logger.warn( - `Fetch for ${ConnectionStatus.getUrlFromInput( - input - )}, got status ${fetchResult.status}` - ); - } - - return fetchResult; - } catch (error) { - logger.warn( - `Fetch for ${ConnectionStatus.getUrlFromInput( - input - )}, got error: ${error}` - ); - throw error; - } - }; - } -} diff --git a/frontend/sync-client/src/services/fetch-controller.test.ts b/frontend/sync-client/src/services/fetch-controller.test.ts new file mode 100644 index 00000000..94fa8424 --- /dev/null +++ b/frontend/sync-client/src/services/fetch-controller.test.ts @@ -0,0 +1,177 @@ +import type { Mock } from "node:test"; +import { describe, it, mock, beforeEach, afterEach } from "node:test"; +import assert from "node:assert"; +import { FetchController } from "./fetch-controller"; +import { Logger } from "../tracing/logger"; +import { SyncResetError } from "./sync-reset-error"; +import { sleep } from "../utils/sleep"; + +describe("FetchController", () => { + const createMockFetch = ( + shouldSleep: boolean + ): Mock<() => Promise> => + mock.fn(async () => { + if (shouldSleep) { + await sleep(30); + } + return Promise.resolve(new Response("OK", { status: 200 })); + }); + + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + afterEach(() => { + mock.timers.reset(); + }); + + it("should allow fetch when canFetch is true", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(false); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + const response = await controlledFetch("http://example.com"); + + assert.strictEqual(await response.text(), "OK"); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + + it("should block fetch until canFetch becomes true", async () => { + const logger = new Logger(); + const controller = new FetchController(false, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + const fetchPromise = controlledFetch("http://example.com"); + assert.strictEqual(mockFetch.mock.calls.length, 0); + + controller.canFetch = true; + await Promise.resolve(); + mock.timers.tick(30); + + const response = await fetchPromise; + assert.strictEqual(await response.text(), "OK"); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + + it("should reject during reset", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + const firstRequest = controlledFetch("http://example.com"); + assert.strictEqual(mockFetch.mock.calls.length, 1); + + controller.startReset(); + + const secondRequest = controlledFetch("http://example.com"); + + await assert.rejects( + firstRequest, + (error: unknown) => error instanceof SyncResetError + ); + await assert.rejects( + secondRequest, + (error: unknown) => error instanceof SyncResetError + ); + assert.strictEqual(mockFetch.mock.calls.length, 1); + }); + + it("should allow fetch after reset finishes", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(false); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + controller.startReset(); + controller.finishReset(); + + const response = await controlledFetch("http://example.com"); + assert.strictEqual(await response.text(), "OK"); + }); + + it("should defer canFetch changes during reset", async () => { + const logger = new Logger(); + const controller = new FetchController(false, logger); + const mockFetch = createMockFetch(true); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + controller.startReset(); + controller.canFetch = true; + + await assert.rejects( + async () => controlledFetch("http://example.com"), + (error: unknown) => error instanceof SyncResetError + ); + + controller.finishReset(); + + const fetchPromise = controlledFetch("http://example.com"); + mock.timers.tick(30); + + const response = await fetchPromise; + assert.strictEqual(await response.text(), "OK"); + }); + + it("should handle different input types", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = createMockFetch(false); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + await controlledFetch("http://example.com"); + await controlledFetch(new URL("http://example.com")); + await controlledFetch( + new Request("http://example.com", { method: "POST" }) + ); + + assert.strictEqual(mockFetch.mock.calls.length, 3); + }); + + it("should handle fetch errors", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + const mockFetch = mock.fn(async () => { + throw new Error("Network error"); + }); + const controlledFetch = controller.getControlledFetchImplementation( + logger, + mockFetch + ); + + await assert.rejects( + async () => controlledFetch("http://example.com"), + (error: unknown) => + error instanceof Error && error.message === "Network error" + ); + }); + + it("should not create unhandled rejection on reset with no waiting fetches", async () => { + const logger = new Logger(); + const controller = new FetchController(true, logger); + + controller.startReset(); + mock.timers.tick(10); + controller.finishReset(); + }); +}); diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts new file mode 100644 index 00000000..77b87e3a --- /dev/null +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -0,0 +1,149 @@ +import type { Logger } from "../tracing/logger"; +import { createPromise } from "../utils/create-promise"; +import { SyncResetError } from "./sync-reset-error"; + +/** + * Offers a resettable fetch implementation that waits until syncing is enabled + * and aborts outstanding requests when a reset is started. + */ +export class FetchController { + private static readonly UNTIL_RESOLUTION = Symbol(); + + private isResetting = false; + + // Promise resolves on the next state change: sync enabled/disabled or reset started/ended + private until: Promise; + private resolveUntil: (result: symbol) => unknown; + private rejectUntil: (reason: unknown) => unknown; + + public constructor( + private _canFetch: boolean, + private readonly logger: Logger + ) { + [this.until, this.resolveUntil, this.rejectUntil] = + createPromise(); + } + + /** + * Whether the fetch implementation can immediately send requests once outside of a reset. + */ + public get canFetch(): boolean { + return this._canFetch; + } + + /** + * Allow or disallow fetching. The changes only take effect if not resetting. + * When called during a reset, its effect is deferred until the reset is finished. + * + * @param canFetch Whether fetching is enabled + */ + public set canFetch(canFetch: boolean) { + this._canFetch = canFetch; + + if (!this.isResetting) { + const previousResolve = this.resolveUntil; + [this.until, this.resolveUntil, this.rejectUntil] = + createPromise(); + previousResolve(FetchController.UNTIL_RESOLUTION); + } + } + + private static getUrlFromInput(input: RequestInfo | URL): string { + if (input instanceof URL) { + return input.href; + } + if (typeof input === "string") { + return input; + } + return input.url; + } + + /** + * Starts a reset, causing all ongoing and future fetches to be rejected + * with a SyncResetError until finishReset is called. + */ + public startReset(): void { + this.isResetting = true; + this.rejectUntil(new SyncResetError()); + // Catch unhandled rejection if no fetches are waiting + this.until.catch(() => { + // Intentionally ignore - this rejection is handled by waiting fetches + }); + } + + /** + * Finishes a reset, allowing fetches to proceed or wait again depending on + * the current sync settings. + */ + public finishReset(): void { + if (!this.isResetting) { + return; + } + + this.isResetting = false; + [this.until, this.resolveUntil, this.rejectUntil] = createPromise(); + } + + /** + * + * |------------------|---------------|-----------------------------------------------------| + * | | Sync enabled | Sync disabled | + * |------------------|-------------- |-----------------------------------------------------| + * | During reset | Rejects with SyncResetError without sending request | + * |------------------|-------------- |-----------------------------------------------------| + * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | + * |------------------|---------------|-----------------------------------------------------| + * + * @param logger for errors + * @param fetch to wrap + * @returns a wrapped fetch implementation affected by the FetchController state + */ + public getControlledFetchImplementation( + logger: Logger, + fetch: typeof globalThis.fetch = globalThis.fetch + ): typeof globalThis.fetch { + return async ( + input: RequestInfo | URL, + init?: RequestInit + ): Promise => { + while (!this.canFetch || this.isResetting) { + await this.until; + } + + try { + // https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21 + const _input = + typeof Request !== "undefined" && input instanceof Request + ? input.clone() + : input; + + const fetchPromise = fetch(_input, init); + + // We only want to catch rejections from `this.until` + let result: symbol | Response | undefined = undefined; + do { + result = await Promise.race([this.until, fetchPromise]); + } while (result === FetchController.UNTIL_RESOLUTION); + + const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + if (!fetchResult.ok) { + this.logger.warn( + `Fetch for ${FetchController.getUrlFromInput( + input + )}, got status ${fetchResult.status}` + ); + } + + return fetchResult; + } catch (error) { + logger.warn( + `Fetch for ${FetchController.getUrlFromInput( + input + )}, got error: ${error}` + ); + throw error; + } + }; + } +} diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts new file mode 100644 index 00000000..309c637c --- /dev/null +++ b/frontend/sync-client/src/services/server-config.ts @@ -0,0 +1,89 @@ +import { SUPPORTED_API_VERSION } from "../consts"; +import { AuthenticationError } from "./authentication-error"; +import { ServerVersionMismatchError } from "./server-version-mismatch-error"; +import type { SyncService } from "./sync-service"; +import type { PingResponse } from "./types/PingResponse"; + +export interface ServerConfigData { + mergeableFileExtensions: string[]; + supportedApiVersion: number; + isAuthenticated: boolean; +} + +export class ServerConfig { + private response: Promise | undefined; + private config: ServerConfigData | undefined; + + public constructor(private readonly syncService: SyncService) {} + + private static validateConfig(config: ServerConfigData): void { + if (config.supportedApiVersion !== SUPPORTED_API_VERSION) { + const shouldUpgradeClient = + config.supportedApiVersion > SUPPORTED_API_VERSION; + throw new ServerVersionMismatchError( + `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${ + shouldUpgradeClient ? "client" : "sync-server" + } to ensure compatibility` + ); + } + + if (!config.isAuthenticated) { + throw new AuthenticationError( + "Failed to authenticate with the sync-server" + ); + } + } + + // warm the cache + public async initialize(): Promise { + await this.getConfig(); + } + + public async checkConnection(forceUpdate = false): Promise<{ + isSuccessful: boolean; + message: string; + }> { + try { + let { response } = this; + if (!response || forceUpdate) { + response = this.response = this.syncService.ping(); + } + + const result: PingResponse = await response; // it must be defined, otherwise we would have thrown above + this.config = result; + + if (result.isAuthenticated) { + return { + isSuccessful: true, + message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated` + }; + } + + return { + isSuccessful: false, + message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate` + }; + } catch (e) { + return { + isSuccessful: false, + message: `Failed to connect to server: ${e}` + }; + } + } + + public async getConfig(): Promise { + if (!this.config) { + this.response ??= this.syncService.ping(); + this.config = await this.response; + } + + ServerConfig.validateConfig(this.config); + + return this.config; + } + + public reset(): void { + this.response = undefined; + this.config = undefined; + } +} diff --git a/frontend/sync-client/src/services/server-version-mismatch-error.ts b/frontend/sync-client/src/services/server-version-mismatch-error.ts new file mode 100644 index 00000000..0b9960ea --- /dev/null +++ b/frontend/sync-client/src/services/server-version-mismatch-error.ts @@ -0,0 +1,6 @@ +export class ServerVersionMismatchError extends Error { + public constructor(message: string) { + super(message); + this.name = "ServerVersionMismatchError"; + } +} diff --git a/frontend/sync-client/src/services/sync-reset-error.ts b/frontend/sync-client/src/services/sync-reset-error.ts index 5e27dfb6..7b74e0b9 100644 --- a/frontend/sync-client/src/services/sync-reset-error.ts +++ b/frontend/sync-client/src/services/sync-reset-error.ts @@ -1,6 +1,6 @@ export class SyncResetError extends Error { - public constructor() { - super("Sync was reset"); - this.name = "SyncResetError"; - } + public constructor() { + super("SyncClient has been reset, cleaning up"); + this.name = "SyncResetError"; + } } diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 8ce9c56a..8190a638 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -1,12 +1,12 @@ import type { - DocumentId, - RelativePath, - VaultUpdateId + DocumentId, + RelativePath, + VaultUpdateId } from "../persistence/database"; import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; -import type { ConnectionStatus } from "./connection-status"; +import type { FetchController } from "./fetch-controller"; import { sleep } from "../utils/sleep"; import { SyncResetError } from "./sync-reset-error"; import type { SerializedError } from "./types/SerializedError"; @@ -16,310 +16,419 @@ import type { DocumentVersion } from "./types/DocumentVersion"; import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; import type { PingResponse } from "./types/PingResponse"; import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; - -export interface CheckConnectionResult { - isSuccessful: boolean; - message: string; -} +import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion"; export class SyncService { - private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; - private readonly client: typeof globalThis.fetch; - private readonly pingClient: typeof globalThis.fetch; + private readonly client: typeof globalThis.fetch; + private readonly pingClient: typeof globalThis.fetch; - public constructor( - private readonly deviceId: string, - private readonly connectionStatus: ConnectionStatus, - private readonly settings: Settings, - private readonly logger: Logger, - fetchImplementation: typeof globalThis.fetch = globalThis.fetch - ) { - // ensure that if it's called a method, `this` won't be bound to the instance - const unboundFetch: typeof globalThis.fetch = async (...args) => - fetchImplementation(...args); + public constructor( + private readonly deviceId: string, + private readonly fetchController: FetchController, + private readonly settings: Settings, + private readonly logger: Logger, + fetchImplementation: typeof globalThis.fetch = globalThis.fetch + ) { + // ensure that if it's called a method, `this` won't be bound to the instance + const unboundFetch: typeof globalThis.fetch = async (...args) => + fetchImplementation(...args); - this.client = this.connectionStatus.getFetchImplementation( - this.logger, - unboundFetch - ); - this.pingClient = unboundFetch; - } + this.client = this.fetchController.getControlledFetchImplementation( + this.logger, + unboundFetch + ); + this.pingClient = unboundFetch; + } - private static formatError(error: SerializedError): string { - let result = error.message; - if (error.causes.length > 0) { - const causes = error.causes.join(", "); - result += ` caused by: ${causes}`; - } + private static async errorFromResponse( + response: Response + ): Promise { + if ( + response.headers + .get("Content-Type") + ?.includes("application/json") == true + ) { + const result: SerializedError = + (await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + return SyncService.formatError(result); + } + return `HTTP ${response.status}: ${response.statusText}`; + } - return result; - } + private static formatError(error: SerializedError): string { + let result = error.message; + if (error.causes.length > 0) { + const causes = error.causes.join(", "); + result += ` caused by: ${causes}`; + } - public async create({ - documentId, - relativePath, - contentBytes - }: { - documentId?: DocumentId; - relativePath: RelativePath; - contentBytes: Uint8Array; - }): Promise { - return this.withRetries(async () => { - const formData = new FormData(); - if (documentId !== undefined) { - formData.append("document_id", documentId); - } - formData.append("relative_path", relativePath); - formData.append( - "content", - new Blob([new Uint8Array(contentBytes)]) - ); + return result; + } - const response = await this.client(this.getUrl("/documents"), { - method: "POST", - body: formData, - headers: this.getDefaultHeaders() - }); + public async create({ + documentId, + relativePath, + contentBytes + }: { + documentId?: DocumentId; + relativePath: RelativePath; + contentBytes: Uint8Array; + }): Promise { + return this.retryForever(async () => { + const formData = new FormData(); + if (documentId !== undefined) { + formData.append("document_id", documentId); + } + formData.append("relative_path", relativePath); + formData.append( + "content", + new Blob([new Uint8Array(contentBytes)]) + ); - const result: SerializedError | DocumentVersionWithoutContent = - (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - | SerializedError - | DocumentVersionWithoutContent; + this.logger.debug( + `Creating document with id ${documentId} and relative path ${relativePath}` + ); - if ("errorType" in result) { - throw new Error( - `Failed to create document: ${SyncService.formatError(result)}` - ); - } + const response = await this.client(this.getUrl("/documents"), { + method: "POST", + body: formData, + headers: this.getDefaultHeaders() + }); - this.logger.debug( - `Created document ${JSON.stringify(result)} with id ${ - result.documentId - }` - ); + if (!response.ok) { + throw new Error( + `Failed to create document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - return result; - }); - } + const result: DocumentVersionWithoutContent = + (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - public async put({ - parentVersionId, - documentId, - relativePath, - contentBytes - }: { - parentVersionId: VaultUpdateId; - documentId: DocumentId; - relativePath: RelativePath; - contentBytes: Uint8Array; - }): Promise { - return this.withRetries(async () => { - this.logger.debug( - `Updating document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` - ); - const formData = new FormData(); - formData.append("parent_version_id", parentVersionId.toString()); - formData.append("relative_path", relativePath); - formData.append( - "content", - new Blob([new Uint8Array(contentBytes)]) - ); + this.logger.debug(`Created document ${JSON.stringify(result)}`); - const response = await this.client( - this.getUrl(`/documents/${documentId}`), - { - method: "PUT", - body: formData, - headers: this.getDefaultHeaders() - } - ); + return result; + }); + } - const result: SerializedError | DocumentUpdateResponse = - (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - | SerializedError - | DocumentUpdateResponse; + public async putText({ + parentVersionId, + documentId, + relativePath, + content + }: { + parentVersionId: VaultUpdateId; + documentId: DocumentId; + relativePath: RelativePath; + content: (number | string)[]; + }): Promise { + return this.retryForever(async () => { + this.logger.debug( + `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]` + ); - if ("errorType" in result) { - throw new Error( - `Failed to update document: ${SyncService.formatError(result)}` - ); - } + const request: UpdateTextDocumentVersion = { + parentVersionId, + relativePath, + content + }; - this.logger.debug( - `Updated document ${JSON.stringify(result)} with id ${ - result.documentId - }}` - ); + const response = await this.client( + this.getUrl(`/documents/${documentId}/text`), + { + method: "PUT", + body: JSON.stringify(request), + headers: this.getDefaultHeaders({ type: "json" }) + } + ); - return result; - }); - } + if (!response.ok) { + throw new Error( + `Failed to update document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - public async delete({ - documentId, - relativePath - }: { - documentId: DocumentId; - relativePath: RelativePath; - }): Promise { - return this.withRetries(async () => { - const request: DeleteDocumentVersion = { - relativePath - }; - const response = await this.client( - this.getUrl(`/documents/${documentId}`), - { - method: "DELETE", - body: JSON.stringify(request), - headers: { - "Content-Type": "application/json", - ...this.getDefaultHeaders() - } - } - ); + const result: DocumentUpdateResponse = + (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - const result: SerializedError | DocumentVersionWithoutContent = - (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - | SerializedError - | DocumentVersionWithoutContent; + this.logger.debug( + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId + }}` + ); - if ("errorType" in result) { - throw new Error( - `Failed to delete document: ${SyncService.formatError(result)}` - ); - } + return result; + }); + } - this.logger.debug( - `Deleted document ${relativePath} with id ${documentId}` - ); + public async putBinary({ + parentVersionId, + documentId, + relativePath, + contentBytes + }: { + parentVersionId: VaultUpdateId; + documentId: DocumentId; + relativePath: RelativePath; + contentBytes: Uint8Array; + }): Promise { + return this.retryForever(async () => { + this.logger.debug( + `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` + ); + const formData = new FormData(); + formData.append("parent_version_id", parentVersionId.toString()); + formData.append("relative_path", relativePath); + formData.append( + "content", + new Blob([new Uint8Array(contentBytes)]) + ); - return result; - }); - } + const response = await this.client( + this.getUrl(`/documents/${documentId}/binary`), + { + method: "PUT", + body: formData, + headers: this.getDefaultHeaders() + } + ); - public async get({ - documentId - }: { - documentId: DocumentId; - }): Promise { - return this.withRetries(async () => { - const response = await this.client( - this.getUrl(`/documents/${documentId}`), - { - headers: this.getDefaultHeaders() - } - ); + if (!response.ok) { + throw new Error( + `Failed to update document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - const result: SerializedError | DocumentVersion = - (await response.json()) as SerializedError | DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + const result: DocumentUpdateResponse = + (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - if ("errorType" in result) { - throw new Error( - `Failed to get document: ${SyncService.formatError(result)}` - ); - } + this.logger.debug( + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId + }}` + ); - this.logger.debug( - `Get document ${result.relativePath} with id ${result.documentId}` - ); + return result; + }); + } - return result; - }); - } + public async delete({ + documentId, + relativePath + }: { + documentId: DocumentId; + relativePath: RelativePath; + }): Promise { + return this.retryForever(async () => { + const request: DeleteDocumentVersion = { + relativePath + }; - public async getAll( - since?: VaultUpdateId - ): Promise { - return this.withRetries(async () => { - const url = new URL(this.getUrl("/documents")); - if (since !== undefined) { - url.searchParams.append("since", since.toString()); - } - const response = await this.client(url.toString(), { - headers: this.getDefaultHeaders() - }); + this.logger.debug( + `Delete document with id ${documentId} and relative path ${relativePath}` + ); - const result: SerializedError | FetchLatestDocumentsResponse = - (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - | SerializedError - | FetchLatestDocumentsResponse; + const response = await this.client( + this.getUrl(`/documents/${documentId}`), + { + method: "DELETE", + body: JSON.stringify(request), + headers: this.getDefaultHeaders({ type: "json" }) + } + ); - if ("errorType" in result) { - throw new Error( - `Failed to get documents: ${SyncService.formatError(result)}` - ); - } + if (!response.ok) { + throw new Error( + `Failed to delete document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - this.logger.debug( - `Got ${result.latestDocuments.length} document metadata` - ); + const result: DocumentVersionWithoutContent = + (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - return result; - }); - } + this.logger.debug( + `Deleted document ${relativePath} with id ${documentId}` + ); - public async checkConnection(): Promise { - try { - const response = await this.pingClient(this.getUrl("/ping"), { - headers: this.getDefaultHeaders() - }); - const result: PingResponse | SerializedError = - (await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + return result; + }); + } - if ("errorType" in result) { - throw new Error( - `Failed to ping server: ${SyncService.formatError(result)}` - ); - } + public async get({ + documentId + }: { + documentId: DocumentId; + }): Promise { + return this.retryForever(async () => { + this.logger.debug(`Getting document with id ${documentId}`); - if (result.isAuthenticated) { - return { - isSuccessful: true, - message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated` - }; - } + const response = await this.client( + this.getUrl(`/documents/${documentId}`), + { + headers: this.getDefaultHeaders() + } + ); - return { - isSuccessful: false, - message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate` - }; - } catch (e) { - return { - isSuccessful: false, - message: `Failed to connect to server: ${e}` - }; - } - } + if (!response.ok) { + throw new Error( + `Failed to get document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - private getUrl(path: string): string { - const { vaultName, remoteUri } = this.settings.getSettings(); - const safeRemoteUri = remoteUri.replace(/\/+$/, ""); - return `${safeRemoteUri}/vaults/${vaultName}${path}`; - } + const result: DocumentVersion = + (await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - private getDefaultHeaders(): Record { - return { - "device-id": this.deviceId, - authorization: `Bearer ${this.settings.getSettings().token}` - }; - } + this.logger.debug(`Got document ${JSON.stringify(result)}`); - private async withRetries(fn: () => Promise): Promise { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - try { - return await fn(); - } catch (e) { - // We must not retry errors coming from reset - if (e instanceof SyncResetError) { - throw e; - } + return result; + }); + } - this.logger.error( - `Failed network call (${e}), retrying in ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms` - ); - await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS); - } - } - } + public async getDocumentVersionContent({ + documentId, + vaultUpdateId + }: { + documentId: DocumentId; + vaultUpdateId: VaultUpdateId; + }): Promise { + return this.retryForever(async () => { + this.logger.debug( + `Getting document with id ${documentId} and version ${vaultUpdateId}` + ); + + const response = await this.client( + this.getUrl( + `/documents/${documentId}/versions/${vaultUpdateId}/content` + ), + { + headers: this.getDefaultHeaders() + } + ); + + if (!response.ok) { + throw new Error( + `Failed to get document: ${await SyncService.errorFromResponse( + response + )}` + ); + } + + const result = await response.bytes(); + this.logger.debug( + `Got document version content for document ${documentId} version ${vaultUpdateId}` + ); + return result; + }); + } + + public async getAll( + since?: VaultUpdateId + ): Promise { + return this.retryForever(async () => { + this.logger.debug( + "Getting all documents" + + (since != null ? ` since ${since}` : "") + ); + + const url = new URL(this.getUrl("/documents")); + if (since !== undefined) { + url.searchParams.append("since", since.toString()); + } + const response = await this.client(url.toString(), { + headers: this.getDefaultHeaders() + }); + + if (!response.ok) { + throw new Error( + `Failed to get documents: ${await SyncService.errorFromResponse( + response + )}` + ); + } + + const result: FetchLatestDocumentsResponse = + (await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + this.logger.debug( + `Got ${result.latestDocuments.length} document metadata` + ); + + return result; + }); + } + + public async ping(): Promise { + this.logger.debug("Pinging server"); + const response = await this.pingClient(this.getUrl("/ping"), { + headers: this.getDefaultHeaders() + }); + + if (!response.ok) { + throw new Error( + `Failed to ping server: ${await SyncService.errorFromResponse( + response + )}` + ); + } + + const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + + this.logger.debug( + `Pinged server, got response: ${JSON.stringify(result)}` + ); + + return result; + } + + private getUrl(path: string): string { + const { vaultName, remoteUri } = this.settings.getSettings(); + const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, ""); + const encodedVaultName = encodeURIComponent(vaultName.trim()); + return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`; + } + + private getDefaultHeaders( + { type }: { type?: "json" } = { type: undefined } + ): Record { + const headers: Record = { + "device-id": this.deviceId, + authorization: `Bearer ${this.settings.getSettings().token}` + }; + + if (type === "json") { + headers["Content-Type"] = "application/json"; + } + + return headers; + } + + private async retryForever(fn: () => Promise): Promise { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + try { + return await fn(); + } catch (e) { + // We must not retry errors coming from reset + if (e instanceof SyncResetError) { + throw e; + } + + const retryInterval = + this.settings.getSettings().networkRetryIntervalMs; + this.logger.error( + `Failed network call (${e}), retrying in ${retryInterval}ms` + ); + await sleep(retryInterval); + } + } + } } diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index 8222bfb0..e8c9b93d 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -2,7 +2,7 @@ import type { DocumentWithCursors } from "./DocumentWithCursors"; export interface ClientCursors { - userName: string; - deviceId: string; - documentsWithCursors: DocumentWithCursors[]; + userName: string; + deviceId: string; + documentsWithCursors: DocumentWithCursors[]; } diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index d4bd376b..ed921f18 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,13 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface CreateDocumentVersion { - /** - * The client can decide the document id (if it wishes to) in order - * to help with syncing. If the client does not provide a document id, - * the server will generate one. If the client provides a document id - * it must not already exist in the database. - */ - document_id: string | null; - relative_path: string; - content: number[]; + /** + * The client can decide the document id (if it wishes to) in order + * to help with syncing. If the client does not provide a document id, + * the server will generate one. If the client provides a document id + * it must not already exist in the database. + */ + document_id: string | null; + relative_path: string; + content: number[]; } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index ca940e3e..ee937f4e 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -2,5 +2,5 @@ import type { DocumentWithCursors } from "./DocumentWithCursors"; export interface CursorPositionFromClient { - documentsWithCursors: DocumentWithCursors[]; + documentsWithCursors: DocumentWithCursors[]; } diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts index 2556b748..52a24f27 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -2,5 +2,5 @@ import type { ClientCursors } from "./ClientCursors"; export interface CursorPositionFromServer { - clients: ClientCursors[]; + clients: ClientCursors[]; } diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index 5bc2542e..2cc2b7fc 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface CursorSpan { - start: number; - end: number; + start: number; + end: number; } diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 9edb09ed..99ecc9e7 100644 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,5 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface DeleteDocumentVersion { - relativePath: string; + relativePath: string; } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index f0ed7abf..7fd06c7a 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -6,5 +6,5 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont * Response to an update document request. */ export type DocumentUpdateResponse = - | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) - | ({ type: "MergingUpdate" } & DocumentVersion); + | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) + | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 2076d296..3b9aa37b 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,12 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface DocumentVersion { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - contentBase64: string; - isDeleted: boolean; - userId: string; - deviceId: string; + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + contentBase64: string; + isDeleted: boolean; + userId: string; + deviceId: string; } diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index cb23f6a5..4b24e7c5 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -1,12 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface DocumentVersionWithoutContent { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - isDeleted: boolean; - userId: string; - deviceId: string; - contentSize: number; + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + isDeleted: boolean; + userId: string; + deviceId: string; + contentSize: number; } diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts index dae654c7..dcfe6e2d 100644 --- a/frontend/sync-client/src/services/types/DocumentWithCursors.ts +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -2,8 +2,8 @@ import type { CursorSpan } from "./CursorSpan"; export interface DocumentWithCursors { - vault_update_id: number | null; - document_id: string; - relative_path: string; - cursors: CursorSpan[]; + vault_update_id: number | null; + document_id: string; + relative_path: string; + cursors: CursorSpan[]; } diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index 67c19b2d..160c9279 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -5,9 +5,9 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont * Response to a fetch latest documents request. */ export interface FetchLatestDocumentsResponse { - latestDocuments: DocumentVersionWithoutContent[]; - /** - * The update ID of the latest document in the response. - */ - lastUpdateId: bigint; + latestDocuments: DocumentVersionWithoutContent[]; + /** + * The update ID of the latest document in the response. + */ + lastUpdateId: bigint; } diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index b0d993f2..6db66354 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -4,13 +4,22 @@ * Response to a ping request. */ export interface PingResponse { - /** - * Semantic version of the server. - */ - serverVersion: string; - /** - * Whether the client is authenticated based on the sent Authorization - * header. - */ - isAuthenticated: boolean; + /** + * Semantic version of the server. + */ + serverVersion: string; + /** + * Whether the client is authenticated based on the sent Authorization + * header. + */ + isAuthenticated: boolean; + /** + * List of file extensions that are allowed to be merged. + */ + mergeableFileExtensions: string[]; + /** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ + supportedApiVersion: number; } diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts index c0979c5a..ec1c4503 100644 --- a/frontend/sync-client/src/services/types/SerializedError.ts +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface SerializedError { - errorType: string; - message: string; - causes: string[]; + errorType: string; + message: string; + causes: string[]; } diff --git a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts index bc3d54e5..4e57a297 100644 --- a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface UpdateDocumentVersion { - parent_version_id: bigint; - relative_path: string; - content: number[]; + parent_version_id: bigint; + relative_path: string; + content: number[]; } diff --git a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts new file mode 100644 index 00000000..46f36bd0 --- /dev/null +++ b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface UpdateTextDocumentVersion { + parentVersionId: number; + relativePath: string; + content: (number | string)[]; +} diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts index e7de2cf3..9608f3af 100644 --- a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -3,5 +3,5 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; export type WebSocketClientMessage = - | ({ type: "handshake" } & WebSocketHandshake) - | ({ type: "cursorPositions" } & CursorPositionFromClient); + | ({ type: "handshake" } & WebSocketHandshake) + | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts index 068b3505..a2910f49 100644 --- a/frontend/sync-client/src/services/types/WebSocketHandshake.ts +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface WebSocketHandshake { - token: string; - deviceId: string; - lastSeenVaultUpdateId: number | null; + token: string; + deviceId: string; + lastSeenVaultUpdateId: number | null; } diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index 8ebf8911..fd250b7b 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -3,5 +3,5 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; export type WebSocketServerMessage = - | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) - | ({ type: "cursorPositions" } & CursorPositionFromServer); + | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) + | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index ad50c25d..f1ea0f80 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -2,6 +2,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; export interface WebSocketVaultUpdate { - documents: DocumentVersionWithoutContent[]; - isInitialSync: boolean; + documents: DocumentVersionWithoutContent[]; + isInitialSync: boolean; } diff --git a/frontend/sync-client/src/services/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts new file mode 100644 index 00000000..fef901e7 --- /dev/null +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -0,0 +1,298 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import { WebSocketManager } from "./websocket-manager"; +import type { Logger } from "../tracing/logger"; +import type { Settings } from "../persistence/settings"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const WebSocket = require("ws") as typeof globalThis.WebSocket; + +class MockCloseEvent extends Event { + public code: number; + public reason: string; + + public constructor( + type: string, + options: { code: number; reason: string } + ) { + super(type); + this.code = options.code; + this.reason = options.reason; + } +} + +class MockMessageEvent extends Event { + public data: string; + + public constructor(type: string, options: { data: string }) { + super(type); + this.data = options.data; + } +} + +class MockWebSocket { + public readyState: number = WebSocket.CONNECTING; + public onopen: ((event: Event) => void) | null = null; + public onclose: ((event: MockCloseEvent) => void) | null = null; + public onmessage: ((event: MockMessageEvent) => void) | null = null; + public onerror: ((event: Event) => void) | null = null; + + public sentMessages: string[] = []; + + public constructor(public url: string) { + setTimeout(() => { + if (this.readyState === WebSocket.CONNECTING) { + this.readyState = WebSocket.OPEN; + this.onopen?.(new Event("open")); + } + }, 0); + } + + public send(data: string): void { + if (this.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not open"); + } + this.sentMessages.push(data); + } + + public close(code?: number, reason?: string): void { + this.readyState = WebSocket.CLOSED; + this.onclose?.( + new MockCloseEvent("close", { + code: code ?? 1000, + reason: reason ?? "" + }) + ); + } + + public simulateMessage(data: unknown): void { + this.onmessage?.( + new MockMessageEvent("message", { data: JSON.stringify(data) }) + ); + } +} + +type MockFn unknown> = T & { + calls: Parameters[]; +}; + +function createMockFn unknown>( + implementation?: T +): MockFn { + const calls: Parameters[] = []; + const mockFn = ((...args: Parameters) => { + calls.push(args); + return implementation?.(...args); + }) as unknown as MockFn; + mockFn.calls = calls; + return mockFn; +} + +describe("WebSocketManager", () => { + let mockLogger: Logger = undefined as unknown as Logger; + let mockSettings: Settings = undefined as unknown as Settings; + let deviceId = "test-device-123"; + + beforeEach(() => { + deviceId = "test-device-123"; + const noop = (): void => { + // Intentionally empty for mock + }; + mockLogger = { + info: createMockFn(noop), + warn: createMockFn(noop), + error: createMockFn(noop), + debug: createMockFn(noop) + } as unknown as Logger; + + mockSettings = { + getSettings: () => ({ + remoteUri: "https://example.com", + vaultName: "test-vault", + webSocketRetryIntervalMs: 1000 + }) + } as unknown as Settings; + }); + + it("cleans up promises after message handling", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + manager.onRemoteVaultUpdateReceived.add(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const { outstandingPromises } = manager as unknown as { + outstandingPromises: Promise[]; + }; + const mockWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + + mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.strictEqual(outstandingPromises.length, 0); + await manager.stop(); + }); + + it("cleans up cursor position promises", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + manager.onRemoteCursorsUpdateReceived.add(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const { outstandingPromises } = manager as unknown as { + outstandingPromises: Promise[]; + }; + const mockWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + + mockWs.simulateMessage({ + type: "cursorPositions", + clients: [{ deviceId: "other-device", cursors: [] }] + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.strictEqual(outstandingPromises.length, 0); + await manager.stop(); + }); + + it("logs handshake send errors", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const mockWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + mockWs.send = (): void => { + throw new Error("Buffer full"); + }; + + assert.throws(() => { + manager.sendHandshakeMessage({ + type: "handshake", + token: "test", + deviceId: "test", + lastSeenVaultUpdateId: null + }); + }); + + await manager.stop(); + }); + + it("completes stop with timeout protection", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + await manager.stop(); + assert.ok(true); + }); + + it("clears old handlers on reconnection", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + let statusChangeCount = 0; + manager.onWebSocketStatusChanged.add(() => { + statusChangeCount++; + }); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const firstWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + + statusChangeCount = 0; + + ( + manager as unknown as { initializeWebSocket: () => void } + ).initializeWebSocket(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + statusChangeCount = 0; + + // Old handler should be cleared + firstWs.onclose?.( + new MockCloseEvent("close", { code: 1000, reason: "test" }) + ); + + assert.strictEqual(statusChangeCount, 0); + await manager.stop(); + }); + + it("tracks message handling promises", async () => { + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let resolveListener: () => void; + const listenerPromise = new Promise((resolve) => { + resolveListener = resolve; + }); + + manager.onRemoteVaultUpdateReceived.add(async () => { + await listenerPromise; + }); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const mockWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const { outstandingPromises } = manager as unknown as { + outstandingPromises: Promise[]; + }; + + assert.ok(outstandingPromises.length > 0); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + resolveListener!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.strictEqual(outstandingPromises.length, 0); + await manager.stop(); + }); +}); diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index a30774f4..09787bce 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -1,199 +1,273 @@ -import type { Database } from "../persistence/database"; import type { Logger } from "../tracing/logger"; -import type { Settings, SyncSettings } from "../persistence/settings"; +import type { Settings } from "../persistence/settings"; import type { WebSocketServerMessage } from "./types/WebSocketServerMessage"; -import type { Syncer } from "../sync-operations/syncer"; import type { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"; import type { ClientCursors } from "./types/ClientCursors"; +import { createPromise } from "../utils/create-promise"; +import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; +import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts"; +import { removeFromArray } from "../utils/remove-from-array"; +import { EventListeners } from "../utils/data-structures/event-listeners"; +import { awaitAll } from "../utils/await-all"; export class WebSocketManager { - private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; - private readonly remoteCursorsUpdateListeners: (( - cursors: ClientCursors[] - ) => unknown)[] = []; + public readonly onWebSocketStatusChanged = new EventListeners< + (isConnected: boolean) => unknown + >(); - private webSocket: WebSocket | undefined; + public readonly onRemoteVaultUpdateReceived = new EventListeners< + (update: WebSocketVaultUpdate) => Promise + >(); - private isStopped = true; - private _isFirstSyncCompleted = false; + public readonly onRemoteCursorsUpdateReceived = new EventListeners< + (cursors: ClientCursors[]) => Promise + >(); - private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; + private isStopped = true; + private resolveDisconnectingPromise: null | (() => unknown) = null; + private reconnectTimeoutId: ReturnType | undefined; - public constructor( - private readonly deviceId: string, - private readonly logger: Logger, - private readonly database: Database, - private readonly settings: Settings, - private readonly syncer: Syncer, - webSocketImplementation?: typeof globalThis.WebSocket - ) { - if (webSocketImplementation) { - this.webSocketFactoryImplementation = webSocketImplementation; - } else { - if ( - typeof globalThis !== "undefined" && - typeof globalThis.WebSocket === "undefined" - ) { - // eslint-disable-next-line - this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js - } else { - this.webSocketFactoryImplementation = WebSocket; - } - } + private readonly outstandingPromises: Promise[] = []; - settings.addOnSettingsChangeListener((newSettings, oldSettings) => { - if ( - newSettings.remoteUri !== oldSettings.remoteUri || - newSettings.vaultName !== oldSettings.vaultName || - newSettings.token !== oldSettings.token - ) { - this.initializeWebSocket(newSettings); - } - }); - } + private webSocket: WebSocket | undefined; + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; - public get isWebSocketConnected(): boolean { - return ( - this.webSocket?.readyState === - this.webSocketFactoryImplementation.OPEN - ); - } + public constructor( + private readonly deviceId: string, + private readonly logger: Logger, + private readonly settings: Settings, + webSocketImplementation?: typeof globalThis.WebSocket + ) { + if (webSocketImplementation) { + this.webSocketFactoryImplementation = webSocketImplementation; + } else { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.WebSocket === "undefined" + ) { + // eslint-disable-next-line + this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js + } else { + this.webSocketFactoryImplementation = WebSocket; + } + } + } - public get isFirstSyncCompleted(): boolean { - return this._isFirstSyncCompleted; - } + public get isWebSocketConnected(): boolean { + return ( + this.webSocket?.readyState === + this.webSocketFactoryImplementation.OPEN + ); + } - public addWebSocketStatusChangeListener(listener: () => unknown): void { - this.webSocketStatusChangeListeners.push(listener); - } + public start(): void { + this.isStopped = false; + this.initializeWebSocket(); + } - public addRemoteCursorsUpdateListener( - listener: (cursors: ClientCursors[]) => unknown - ): void { - this.remoteCursorsUpdateListeners.push(listener); - } + public async stop(): Promise { + const [promise, resolve] = createPromise(); + this.resolveDisconnectingPromise = resolve; - public start(): void { - this.isStopped = false; - this._isFirstSyncCompleted = false; - this.initializeWebSocket(this.settings.getSettings()); - } + this.isStopped = true; - public stop(): void { - this.isStopped = true; - this.webSocket?.close(1000, "WebSocketManager has been stopped"); - } + if (this.reconnectTimeoutId !== undefined) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = undefined; + } - public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { - if (!this.isWebSocketConnected) { - this.logger.warn( - "WebSocket is not connected, cannot send cursor positions" - ); - return; - } - const message: WebSocketClientMessage = { - type: "cursorPositions", - ...cursorPositions - }; - this.webSocket?.send(JSON.stringify(message)); - this.logger.debug( - `Sent cursor positions: ${JSON.stringify(cursorPositions)}` - ); - } + this.webSocket?.close(1000, "WebSocketManager has been stopped"); - private initializeWebSocket(settings: SyncSettings): void { - if (this.isStopped) { - return; - } + // eslint-disable-next-line @typescript-eslint/init-declarations + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds` + ) + ); + }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000); + }); - try { - this.webSocket?.close(); - } catch (e) { - this.logger.warn(`Failed to close WebSocket: ${e}`); - } + try { + while (this.isWebSocketConnected) { + await Promise.race([promise, timeoutPromise]); + } + } catch (error) { + this.logger.error( + `Error while waiting for WebSocket to close: ${String(error)}` + ); + // Force cleanup even if close didn't work + this.resolveDisconnectingPromise(); + this.resolveDisconnectingPromise = null; + } finally { + // Clear timeout to prevent unhandled rejection + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } - const wsUri = new URL(settings.remoteUri); - wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; - wsUri.pathname = `/vaults/${settings.vaultName}/ws`; + await this.waitUntilFinished(); + } - this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); + public async waitUntilFinished(): Promise { + await awaitAll(this.outstandingPromises); + } - this.webSocket = new this.webSocketFactoryImplementation(wsUri); + public sendHandshakeMessage( + message: WebSocketClientMessage & { type: "handshake" } + ): void { + const { webSocket } = this; + if (!webSocket) { + throw new Error( + "WebSocket is not connected, cannot send handshake message" + ); + } - // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message - this.webSocket.onopen = (): void => { - this.logger.info("WebSocket connection opened"); - this.webSocketStatusChangeListeners.forEach((l) => l()); + try { + webSocket.send(JSON.stringify(message)); + } catch (error) { + this.logger.error( + `Failed to send handshake message: ${String(error)}` + ); + throw error; + } + } - const message: WebSocketClientMessage = { - type: "handshake", - deviceId: this.deviceId, - token: settings.token, - lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() - }; - this.webSocket?.send(JSON.stringify(message)); - }; + public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { + if (!this.isWebSocketConnected || !this.webSocket) { + // A missing cursor update is fine, we can just skip it if needed + this.logger.warn( + "WebSocket is not connected, cannot send cursor positions" + ); + return; + } - this.webSocket.onmessage = async (event): Promise => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const message = JSON.parse(event.data) as WebSocketServerMessage; - return this.handleWebSocketMessage(message); - }; + const message: WebSocketClientMessage = { + type: "cursorPositions", + ...cursorPositions + }; - this.webSocket.onclose = (event): void => { - this.logger.warn( - `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` - ); - this.webSocketStatusChangeListeners.forEach((l) => l()); + try { + this.webSocket.send(JSON.stringify(message)); + this.logger.debug( + `Sent cursor positions: ${JSON.stringify(cursorPositions)}` + ); + } catch (error) { + this.logger.warn( + `Failed to send cursor positions: ${String(error)}` + ); + } + } - if (!this.isStopped) { - setTimeout(() => { - this.initializeWebSocket(this.settings.getSettings()); - }, this.settings.getSettings().webSocketRetryIntervalMs); - } - }; - } + private initializeWebSocket(): void { + // Clean up old WebSocket handlers to prevent race conditions + if (this.webSocket) { + try { + // Remove handlers to prevent them from firing after new connection + this.webSocket.onopen = null; + this.webSocket.onclose = null; + this.webSocket.onmessage = null; + this.webSocket.onerror = null; + this.webSocket.close(); + } catch (e) { + this.logger.error( + `Failed to close previous WebSocket connection: ${e}` + ); + } + } - private async handleWebSocketMessage( - message: WebSocketServerMessage - ): Promise { - if (message.type === "vaultUpdate") { - try { - await Promise.all( - message.documents.map(async (document) => - this.syncer.syncRemotelyUpdatedFile(document) - ) - ); + const wsUri = new URL(this.settings.getSettings().remoteUri); + wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; + wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`; - if (message.isInitialSync && message.documents.length > 0) { - this.database.setLastSeenUpdateId( - message.documents - .map((document) => document.vaultUpdateId) - .reduce((a, b) => Math.max(a, b)) - ); - } + this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); - this._isFirstSyncCompleted = true; - } catch (e) { - this.logger.error(`Failed to sync remotely updated file: ${e}`); - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (message.type === "cursorPositions") { - this.logger.debug( - `Received cursor positions for ${JSON.stringify(message.clients)}` - ); - this.remoteCursorsUpdateListeners.forEach((listener) => { - listener( - message.clients.filter( - (client) => client.deviceId !== this.deviceId - ) - ); - }); - } else { - this.logger.warn( - `Received unknown message type: ${JSON.stringify(message)}` - ); - } - } + this.webSocket = new this.webSocketFactoryImplementation(wsUri); + + this.webSocket.onopen = (): void => { + // Check if we've been stopped while connecting + if (this.isStopped) { + this.webSocket?.close( + 1000, + "WebSocketManager was stopped during connection" + ); + return; + } + this.logger.info("WebSocket connection opened"); + this.onWebSocketStatusChanged.trigger(true); + }; + + this.webSocket.onmessage = (event): void => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = JSON.parse( + event.data + ) as WebSocketServerMessage; + + // Track the message handling promise + const messageHandlingPromise = this.handleWebSocketMessage( + message + ) + .catch((error: unknown) => { + this.logger.error( + `Error handling WebSocket message: ${String(error)}` + ); + }) + .finally(() => { + removeFromArray( + this.outstandingPromises, + messageHandlingPromise + ); + }); + + void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise + } catch (error) { + this.logger.error( + `Error parsing WebSocket message: ${String(error)}` + ); + } + }; + + this.webSocket.onclose = (event): void => { + this.logger.warn( + `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` + ); + this.onWebSocketStatusChanged.trigger(false); + + if (this.isStopped) { + this.resolveDisconnectingPromise?.(); + this.resolveDisconnectingPromise = null; + } else { + this.reconnectTimeoutId = setTimeout(() => { + this.reconnectTimeoutId = undefined; + this.initializeWebSocket(); + }, this.settings.getSettings().webSocketRetryIntervalMs); + } + }; + } + + private async handleWebSocketMessage( + message: WebSocketServerMessage + ): Promise { + if (message.type === "vaultUpdate") { + await this.onRemoteVaultUpdateReceived.triggerAsync(message); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (message.type === "cursorPositions") { + this.logger.debug( + `Received cursor positions for ${JSON.stringify(message.clients)}` + ); + + await this.onRemoteCursorsUpdateReceived.triggerAsync( + message.clients + ); + } else { + this.logger.warn( + `Received unknown message type: ${JSON.stringify(message)}` + ); + } + } } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 78beb910..2a272c86 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -1,16 +1,17 @@ import type { PersistenceProvider } from "./persistence/persistence"; import type { HistoryEntry, HistoryStats } from "./tracing/sync-history"; import { SyncHistory } from "./tracing/sync-history"; -import { Logger } from "./tracing/logger"; +import { Logger, LogLevel, LogLine } from "./tracing/logger"; import type { RelativePath, StoredDatabase } from "./persistence/database"; import { Database } from "./persistence/database"; +import * as Sentry from "@sentry/browser"; import type { SyncSettings } from "./persistence/settings"; -import { Settings } from "./persistence/settings"; +import { DEFAULT_SETTINGS, Settings } from "./persistence/settings"; import { SyncService } from "./services/sync-service"; import { Syncer } from "./sync-operations/syncer"; import type { FileSystemOperations } from "./file-operations/filesystem-operations"; import { FileOperations } from "./file-operations/file-operations"; -import { ConnectionStatus } from "./services/connection-status"; +import { FetchController } from "./services/fetch-controller"; import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; import { rateLimit } from "./utils/rate-limit"; import type { NetworkConnectionStatus } from "./types/network-connection-status"; @@ -21,317 +22,522 @@ import { CursorTracker } from "./sync-operations/cursor-tracker"; import type { CursorSpan } from "./services/types/CursorSpan"; import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; import { FileChangeNotifier } from "./sync-operations/file-change-notifier"; +import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache"; +import { setUpTelemetry } from "./utils/set-up-telemetry"; +import { DIFF_CACHE_SIZE_MB } from "./consts"; +import { ServerConfig } from "./services/server-config"; +import type { EventListeners } from "./utils/data-structures/event-listeners"; export class SyncClient { - private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; - private hasStartedOfflineSync = false; - private hasFinishedOfflineSync = false; + private hasStartedOfflineSync = false; + private hasFinishedOfflineSync = false; + private hasStarted = false; + private hasBeenDestroyed = false; + private unloadTelemetry?: () => void; + private isDestroying = false; + private readonly eventUnsubscribers: (() => void)[] = []; - // eslint-disable-next-line @typescript-eslint/max-params - private constructor( - private readonly history: SyncHistory, - private readonly settings: Settings, - private readonly database: Database, - private readonly syncer: Syncer, - private readonly syncService: SyncService, - private readonly webSocketManager: WebSocketManager, - private readonly _logger: Logger, - private readonly connectionStatus: ConnectionStatus, - private readonly cursorTracker: CursorTracker, - private readonly fileChangeNotifier: FileChangeNotifier - ) { - this.settings.addOnSettingsChangeListener( - async (newSettings, oldSettings) => { - if (newSettings.vaultName !== oldSettings.vaultName) { - await this.reset(); - } + private constructor( + private readonly history: SyncHistory, + private readonly settings: Settings, + private readonly database: Database, + private readonly syncer: Syncer, + private readonly webSocketManager: WebSocketManager, + public readonly logger: Logger, + private readonly fetchController: FetchController, + private readonly cursorTracker: CursorTracker, + private readonly fileChangeNotifier: FileChangeNotifier, + private readonly contentCache: FixedSizeDocumentCache, + private readonly fileOperations: FileOperations, + private readonly serverConfig: ServerConfig, + private readonly persistence: PersistenceProvider< + Partial<{ + settings: Partial; + database: Partial; + }> + > + ) {} - if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { - if (newSettings.isSyncEnabled) { - await this.start(); - } else { - this.stop(); - } - } - } - ); - } + public get documentCount(): number { + return this.database.length; + } - public get logger(): Logger { - return this._logger; - } + public get isWebSocketConnected(): boolean { + return this.webSocketManager.isWebSocketConnected; + } - public get documentCount(): number { - return this.database.length; - } + public get onSyncHistoryUpdated(): EventListeners< + (stats: HistoryStats) => unknown + > { + this.checkIfDestroyed("onSyncHistoryUpdated getter"); + return this.history.onHistoryUpdated; + } - public static async create({ - fs, - persistence, - fetch, - webSocket, - nativeLineEndings = "\n" - }: { - fs: FileSystemOperations; - persistence: PersistenceProvider< - Partial<{ - settings: Partial; - database: Partial; - }> - >; - fetch?: typeof globalThis.fetch; - webSocket?: typeof globalThis.WebSocket; - nativeLineEndings?: string; - }): Promise { - const logger = new Logger(); + public get onSettingsChanged(): EventListeners< + (newSettings: SyncSettings, oldSettings: SyncSettings) => unknown + > { + this.checkIfDestroyed("onSettingsChanged getter"); + return this.settings.onSettingsChanged; + } - const deviceId = createClientId(); + public get onRemainingOperationsCountChanged(): EventListeners< + (remainingOperationsCount: number) => unknown + > { + this.checkIfDestroyed("onRemainingOperationsCountChanged getter"); + return this.syncer.onRemainingOperationsCountChanged; + } - logger.info(`Initialising SyncClient with client id ${deviceId}`); + public get onWebSocketStatusChanged(): EventListeners< + (isConnected: boolean) => unknown + > { + this.checkIfDestroyed("onWebSocketStatusChanged getter"); + return this.webSocketManager.onWebSocketStatusChanged; + } - const history = new SyncHistory(logger); + public get onRemoteCursorsUpdated(): EventListeners< + (cursors: MaybeOutdatedClientCursors[]) => unknown + > { + this.checkIfDestroyed("onRemoteCursorsUpdated getter"); + return this.cursorTracker.onRemoteCursorsUpdated; + } - let state = (await persistence.load()) ?? { - settings: undefined, - database: undefined - }; + public static async create({ + fs, + persistence, + fetch, + webSocket, + nativeLineEndings = "\n" + }: { + fs: FileSystemOperations; + persistence: PersistenceProvider< + Partial<{ + settings: Partial; + database: Partial; + }> + >; + fetch?: typeof globalThis.fetch; + webSocket?: typeof globalThis.WebSocket; + nativeLineEndings?: string; + }): Promise { + const logger = new Logger(); - const rateLimitedSave = rateLimit( - persistence.save, - SyncClient.MINIMUM_SAVE_INTERVAL_MS - ); + const deviceId = createClientId(); - const database = new Database( - logger, - state.database, - async (data): Promise => { - state = { ...state, database: data }; - await rateLimitedSave(state); - } - ); + logger.info(`Creating SyncClient with client id ${deviceId}`); - const settings = new Settings( - logger, - state.settings, - async (data): Promise => { - state = { ...state, settings: data }; - await rateLimitedSave(state); - } - ); + const history = new SyncHistory(logger); - const connectionStatus = new ConnectionStatus(settings, logger); - const syncService = new SyncService( - deviceId, - connectionStatus, - settings, - logger, - fetch - ); + let state = (await persistence.load()) ?? { + settings: undefined, + database: undefined + }; - const fileOperations = new FileOperations( - logger, - database, - fs, - nativeLineEndings - ); + const settings = new Settings( + logger, + state.settings, + async (data): Promise => { + state = { ...state, settings: data }; + // we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit + // and (2) settings changes are infrequent enough that rate-limiting is not necessary + await persistence.save(state); + } + ); - const unrestrictedSyncer = new UnrestrictedSyncer( - logger, - database, - settings, - syncService, - fileOperations, - history - ); + const rateLimitedSave = rateLimit( + persistence.save, + () => settings.getSettings().minimumSaveIntervalMs + ); - const syncer = new Syncer( - logger, - database, - settings, - syncService, - fileOperations, - unrestrictedSyncer - ); + const database = new Database( + logger, + state.database, + async (data): Promise => { + state = { ...state, database: data }; + await rateLimitedSave(state); + } + ); - const webSocketManager = new WebSocketManager( - deviceId, - logger, - database, - settings, - syncer, - webSocket - ); + const fetchController = new FetchController( + settings.getSettings().isSyncEnabled, + logger + ); - const fileChangeNotifier = new FileChangeNotifier(); - const cursorTracker = new CursorTracker( - database, - webSocketManager, - fileOperations, - fileChangeNotifier - ); - const client = new SyncClient( - history, - settings, - database, - syncer, - syncService, - webSocketManager, - logger, - connectionStatus, - cursorTracker, - fileChangeNotifier - ); + const syncService = new SyncService( + deviceId, + fetchController, + settings, + logger, + fetch + ); - logger.info("SyncClient initialised"); + const serverConfig = new ServerConfig(syncService); - return client; - } + const fileOperations = new FileOperations( + logger, + database, + fs, + serverConfig, + nativeLineEndings + ); - public async checkConnection(): Promise { - const server = await this.syncService.checkConnection(); - return { - isSuccessful: server.isSuccessful, - serverMessage: server.message, - isWebSocketConnected: this.webSocketManager.isWebSocketConnected - }; - } + const contentCache = new FixedSizeDocumentCache( + 1024 * 1024 * DIFF_CACHE_SIZE_MB + ); + const unrestrictedSyncer = new UnrestrictedSyncer( + logger, + database, + settings, + syncService, + fileOperations, + history, + contentCache, + serverConfig + ); - public getHistoryEntries(): readonly HistoryEntry[] { - return this.history.entries; - } + const webSocketManager = new WebSocketManager( + deviceId, + logger, + settings, + webSocket + ); - public addSyncHistoryUpdateListener( - listener: (stats: HistoryStats) => unknown - ): void { - this.history.addSyncHistoryUpdateListener(listener); - } + const syncer = new Syncer( + deviceId, + logger, + database, + settings, + syncService, + webSocketManager, + fileOperations, + unrestrictedSyncer + ); - public async start(): Promise { - if (!this.hasStartedOfflineSync) { - await this.syncer.scheduleSyncForOfflineChanges(); - this.hasStartedOfflineSync = true; - } + const fileChangeNotifier = new FileChangeNotifier(); + const cursorTracker = new CursorTracker( + database, + webSocketManager, + fileOperations, + fileChangeNotifier + ); + const client = new SyncClient( + history, + settings, + database, + syncer, + webSocketManager, + logger, + fetchController, + cursorTracker, + fileChangeNotifier, + contentCache, + fileOperations, + serverConfig, + persistence + ); - this.hasFinishedOfflineSync = true; - this.webSocketManager.start(); - } + logger.info("SyncClient created successfully"); - public stop(): void { - this.hasFinishedOfflineSync = false; - this.webSocketManager.stop(); - } + return client; + } - public async waitAndStop(): Promise { - this.stop(); - await this.syncer.waitUntilFinished(); - } + public async start(): Promise { + this.checkIfDestroyed("start"); - /// Wait for the in-flight operations to finish, reset all tracking, - /// and the local database but retain the settings. - /// The SyncClient can be used again after calling this method. - public async reset(): Promise { - this.stop(); - this.connectionStatus.startReset(); - await this.syncer.reset(); - this.history.reset(); - this.database.reset(); - this._logger.reset(); - this.connectionStatus.finishReset(); - await this.start(); - } + if (this.hasStarted) { + throw new Error("SyncClient has already been started"); + } + this.hasStarted = true; - public getSettings(): SyncSettings { - return this.settings.getSettings(); - } + if ( + !this.unloadTelemetry && + this.settings.getSettings().enableTelemetry + ) { + this.unloadTelemetry = setUpTelemetry(); + } - public async setSetting( - key: T, - value: SyncSettings[T] - ): Promise { - await this.settings.setSetting(key, value); - } + this.eventUnsubscribers.push( + this.settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) { + this.fetchController.canFetch = newSettings.isSyncEnabled; + } + }) + ); - public async setSettings(value: Partial): Promise { - await this.settings.setSettings(value); - } + this.eventUnsubscribers.push( + this.logger.onLogEmitted.add((log): void => { + if (log.level === LogLevel.ERROR && Sentry.isInitialized()) { + Sentry.captureMessage(log.message); + } + }) + ); - public addOnSettingsChangeListener( - handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown - ): void { - this.settings.addOnSettingsChangeListener(handler); - } + this.eventUnsubscribers.push( + this.settings.onSettingsChanged.add( + this.onSettingsChange.bind(this) + ) + ); - public addRemainingSyncOperationsListener( - listener: (remainingOperations: number) => unknown - ): void { - this.syncer.addRemainingOperationsListener(listener); - } + if (this.settings.getSettings().isSyncEnabled) { + this.logger.info("Starting SyncClient"); + await this.startSyncing(); + this.logger.info("SyncClient has successfully started"); + } + } - public addWebSocketStatusChangeListener(listener: () => unknown): void { - this.webSocketManager.addWebSocketStatusChangeListener(listener); - } + /** + * Reload settings from disk overriding current in-memory settings. + * Missing values will be filled in from DEFAULT_SETTINGS rather than + * retaining current in-memory settings. + */ + public async reloadSettings(): Promise { + this.checkIfDestroyed("reloadSettings"); - public async syncLocallyCreatedFile( - relativePath: RelativePath - ): Promise { - this.fileChangeNotifier.notifyOfFileChange(relativePath); - return this.syncer.syncLocallyCreatedFile(relativePath); - } + const state = (await this.persistence.load()) ?? { + settings: undefined + }; - public async syncLocallyDeletedFile( - relativePath: RelativePath - ): Promise { - this.fileChangeNotifier.notifyOfFileChange(relativePath); - return this.syncer.syncLocallyDeletedFile(relativePath); - } + const settings = { + ...DEFAULT_SETTINGS, + ...(state.settings ?? {}) + }; - public async syncLocallyUpdatedFile({ - oldPath, - relativePath - }: { - oldPath?: RelativePath; - relativePath: RelativePath; - }): Promise { - this.fileChangeNotifier.notifyOfFileChange(relativePath); - return this.syncer.syncLocallyUpdatedFile({ - oldPath, - relativePath - }); - } + await this.setSettings(settings); + } - public getDocumentSyncingStatus( - relativePath: RelativePath - ): DocumentSyncStatus { - if (!this.settings.getSettings().isSyncEnabled) { - return DocumentSyncStatus.SYNCING_IS_DISABLED; - } + public async checkConnection(): Promise { + this.checkIfDestroyed("checkConnection"); - if ( - !this.webSocketManager.isFirstSyncCompleted || - !this.hasFinishedOfflineSync - ) { - return DocumentSyncStatus.SYNCING; - } + const server = await this.serverConfig.checkConnection(true); + return { + isSuccessful: server.isSuccessful, + serverMessage: server.message, + isWebSocketConnected: this.webSocketManager.isWebSocketConnected + }; + } - const document = - this.database.getLatestDocumentByRelativePath(relativePath); - if (document === undefined) { - return DocumentSyncStatus.SYNCING; - } - return document.updates.length > 0 - ? DocumentSyncStatus.SYNCING - : DocumentSyncStatus.UP_TO_DATE; - } + public getHistoryEntries(): readonly HistoryEntry[] { + return this.history.entries; + } - public async updateLocalCursors( - documentToCursors: Record - ): Promise { - await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); - } + /** + * Wait for the in-flight operations to finish, reset all tracking, + * and the local database but retain the settings. + * The SyncClient can be used again after calling this method. + */ + public async reset(): Promise { + this.checkIfDestroyed("reset"); - public addRemoteCursorsUpdateListener( - listener: (cursors: MaybeOutdatedClientCursors[]) => unknown - ): void { - this.cursorTracker.addRemoteCursorsUpdateListener(listener); - } + this.logger.info( + "Stopping SyncClient to apply changed connection settings" + ); + await this.pause(); + + // clear all local state + this.logger.info("Resetting SyncClient's local state"); + this.database.reset(); + await this.database.save(); // ensure the new database reads as empty + this.resetInMemoryState(); + this.hasStartedOfflineSync = false; + this.hasFinishedOfflineSync = false; + this.serverConfig.reset(); + + await this.startSyncing(); + } + + public getSettings(): SyncSettings { + return this.settings.getSettings(); + } + + public async setSetting( + key: T, + value: SyncSettings[T] + ): Promise { + this.checkIfDestroyed("setSetting"); + + await this.settings.setSetting(key, value); + } + + public async setSettings(value: Partial): Promise { + this.checkIfDestroyed("setSettings"); + + await this.settings.setSettings(value); + } + + public async syncLocallyCreatedFile( + relativePath: RelativePath + ): Promise { + this.checkIfDestroyed("syncLocallyCreatedFile"); + + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyCreatedFile(relativePath); + } + + public async syncLocallyDeletedFile( + relativePath: RelativePath + ): Promise { + this.checkIfDestroyed("syncLocallyDeletedFile"); + + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyDeletedFile(relativePath); + } + + public async syncLocallyUpdatedFile({ + oldPath, + relativePath + }: { + oldPath?: RelativePath; + relativePath: RelativePath; + }): Promise { + this.checkIfDestroyed("syncLocallyUpdatedFile"); + + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyUpdatedFile({ + oldPath, + relativePath + }); + } + + public getDocumentSyncingStatus( + relativePath: RelativePath + ): DocumentSyncStatus { + this.checkIfDestroyed("getDocumentSyncingStatus"); + + if (!this.settings.getSettings().isSyncEnabled) { + return DocumentSyncStatus.SYNCING_IS_DISABLED; + } + + if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) { + return DocumentSyncStatus.SYNCING; + } + + const document = + this.database.getLatestDocumentByRelativePath(relativePath); + if (document === undefined) { + return DocumentSyncStatus.SYNCING; + } + return document.updates.length > 0 + ? DocumentSyncStatus.SYNCING + : DocumentSyncStatus.UP_TO_DATE; + } + + public async updateLocalCursors( + documentToCursors: Record + ): Promise { + this.checkIfDestroyed("updateLocalCursors"); + + await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); + } + + public async waitUntilFinished(): Promise { + this.checkIfDestroyed("waitUntilIdle"); + await this.syncer.waitUntilFinished(); + await this.webSocketManager.waitUntilFinished(); + await this.database.save(); // flush all changes to disk + } + + /** + * Completely destroy the SyncClient, cancelling all in-progress operations. + * After calling this method, the SyncClient cannot be used again. + */ + public async destroy(): Promise { + this.checkIfDestroyed("destroy"); + + // Prevent concurrent destroy calls + if (this.isDestroying) { + this.logger.warn( + "destroy() called while already destroying, ignoring" + ); + return; + } + this.isDestroying = true; + + // cancel everything that's in progress + await this.pause(); + + this.hasBeenDestroyed = true; + + this.resetInMemoryState(); + + // Clean up event listeners to prevent memory leaks + this.eventUnsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); + this.eventUnsubscribers.length = 0; + + this.logger.info("SyncClient has been successfully disposed"); + + this.unloadTelemetry?.(); + } + + private async startSyncing(): Promise { + this.checkIfDestroyed("startSyncing"); + this.fetchController.finishReset(); + + await this.serverConfig.initialize(); + this.webSocketManager.start(); + + if (!this.hasStartedOfflineSync) { + this.hasStartedOfflineSync = true; + await this.syncer.scheduleSyncForOfflineChanges(); + } + + this.hasFinishedOfflineSync = true; + } + + private async pause(): Promise { + this.fetchController.startReset(); + await this.webSocketManager.stop(); + await this.waitUntilFinished(); + } + + private resetInMemoryState(): void { + this.history.reset(); + this.contentCache.reset(); + // don't reset the logger + this.cursorTracker.reset(); + this.syncer.reset(); + this.fileOperations.reset(); + } + + private async onSettingsChange( + newSettings: SyncSettings, + oldSettings: SyncSettings + ): Promise { + this.checkIfDestroyed("onSettingsChange"); + + if ( + newSettings.vaultName !== oldSettings.vaultName || + newSettings.remoteUri !== oldSettings.remoteUri + ) { + await this.reset(); + } + + if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { + if (newSettings.isSyncEnabled) { + await this.startSyncing(); + } else { + await this.pause(); + } + } + + if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { + this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024); + } + + if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { + if (newSettings.enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } else { + this.unloadTelemetry?.(); + } + } + } + + private checkIfDestroyed(origin: string): void { + if (this.hasBeenDestroyed) { + throw new Error( + `SyncClient has been destroyed and can no longer be used; called from ${origin}` + ); + } + } } diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 17f166c4..bdd7d9b7 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -8,246 +8,251 @@ import type { MaybeOutdatedClientCursors } from "../types/maybe-outdated-client- import { DocumentUpToDateness } from "../types/document-up-to-dateness"; import { hash } from "../utils/hash"; import type { FileChangeNotifier } from "./file-change-notifier"; -import { Lock } from "../utils/locks"; +import { Lock } from "../utils/data-structures/locks"; +import { EventListeners } from "../utils/data-structures/event-listeners"; // Cursor positions are updated separately from documents. However, a given cursor position is only // valid within a certain version of the document it belongs to. This class tracks previous and the latest // known remote cursor positions, and for each document, tries to return the latest cursor positions that are // not from the future. export class CursorTracker { - private readonly updateLock = new Lock(); + // The returned position may be accurate, if it matches the document version, or outdated, in which case + // the client has to heuristically guess it's current position based on the local edits. + public readonly onRemoteCursorsUpdated = new EventListeners< + (cursors: MaybeOutdatedClientCursors[]) => unknown + >(); - private knownRemoteCursors: (ClientCursors & { - upToDateness: DocumentUpToDateness; - })[] = []; + private readonly updateLock = new Lock(); - private lastLocalCursorState: DocumentWithCursors[] = []; - private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = - []; + private knownRemoteCursors: (ClientCursors & { + upToDateness: DocumentUpToDateness; + })[] = []; - public constructor( - private readonly database: Database, - private readonly webSocketManager: WebSocketManager, - private readonly fileOperations: FileOperations, - private readonly fileChangeNotifier: FileChangeNotifier - ) { - this.webSocketManager.addRemoteCursorsUpdateListener( - async (clientCursors) => { - await this.updateLock.withLock(async () => { - // The latest message will contain all active clients, so we can delete the ones - // from the local list which are no longer active. - const allIds = new Set( - clientCursors.map((c) => c.deviceId) - ); - const updatedKnownRemoteCursors = - this.knownRemoteCursors.filter((c) => - allIds.has(c.deviceId) - ); + private lastLocalCursorState: DocumentWithCursors[] = []; + private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = + []; - for (const cursor of clientCursors.filter((client) => - client.documentsWithCursors.every( - (doc) => doc.vault_update_id != null - ) - )) { - updatedKnownRemoteCursors.push({ - ...cursor, - upToDateness: - await this.getDocumentsUpToDateness(cursor) - }); - } + public constructor( + private readonly database: Database, + private readonly webSocketManager: WebSocketManager, + private readonly fileOperations: FileOperations, + private readonly fileChangeNotifier: FileChangeNotifier + ) { + this.webSocketManager.onRemoteCursorsUpdateReceived.add( + async (clientCursors) => { + await this.updateLock.withLock(async () => { + // The latest message will contain all active clients, so we can delete the ones + // from the local list which are no longer active. + const allIds = new Set( + clientCursors.map((c) => c.deviceId) + ); + const updatedKnownRemoteCursors = + this.knownRemoteCursors.filter((c) => + allIds.has(c.deviceId) + ); - this.knownRemoteCursors = updatedKnownRemoteCursors; - }); - } - ); + for (const cursor of clientCursors.filter((client) => + client.documentsWithCursors.every( + (doc) => doc.vault_update_id != null + ) + )) { + updatedKnownRemoteCursors.push({ + ...cursor, + upToDateness: + await this.getDocumentsUpToDateness(cursor) + }); + } - this.fileChangeNotifier.addFileChangeListener(async (relativePath) => - this.updateLock.withLock(async () => { - for (const clientCursor of this.knownRemoteCursors) { - if ( - clientCursor.documentsWithCursors.some( - (document) => - document.relative_path === relativePath - ) - ) { - clientCursor.upToDateness = - await this.getDocumentsUpToDateness(clientCursor); - } - } - }) - ); - } + this.knownRemoteCursors = updatedKnownRemoteCursors; + }); - /// Update the local cursors for the given documents. - /// Can be called frequently as it only emits an event - /// if the state has actually changed. - public async sendLocalCursorsToServer( - documentToCursors: Record - ): Promise { - const documentsWithCursors: DocumentWithCursors[] = []; + this.onRemoteCursorsUpdated.trigger( + this.getRelevantAndPruneKnownClientCursors() + ); + } + ); - for (const [relativePath, cursors] of Object.entries( - documentToCursors - )) { - const record = - this.database.getLatestDocumentByRelativePath(relativePath); + this.fileChangeNotifier.onFileChanged.add(async (relativePath) => + this.updateLock.withLock(async () => { + for (const clientCursor of this.knownRemoteCursors) { + if ( + clientCursor.documentsWithCursors.some( + (document) => + document.relative_path === relativePath + ) + ) { + clientCursor.upToDateness = + await this.getDocumentsUpToDateness(clientCursor); + } + } + }) + ); + } - if (!record) { - continue; // Let's wait for the file to be created before sending cursors - } + /// Update the local cursors for the given documents. + /// Can be called frequently as it only emits an event + /// if the state has actually changed. + public async sendLocalCursorsToServer( + documentToCursors: Record + ): Promise { + const documentsWithCursors: DocumentWithCursors[] = []; - if (!record.metadata) { - continue; // this is a new document, no need to sync the cursors - } + for (const [relativePath, cursors] of Object.entries( + documentToCursors + )) { + const record = + this.database.getLatestDocumentByRelativePath(relativePath); - documentsWithCursors.push({ - relative_path: relativePath, - document_id: record.documentId, - vault_update_id: record.metadata.parentVersionId, - cursors: cursors.map(({ start, end }) => ({ - start: Math.min(start, end), - end: Math.max(start, end) - })) // the client might send directional selections - }); - } + if (!record) { + continue; // Let's wait for the file to be created before sending cursors + } - if ( - JSON.stringify(this.lastLocalCursorState) === - JSON.stringify(documentsWithCursors) - ) { - // Caching step to avoid reading the edited files all the time - return; - } - this.lastLocalCursorState = documentsWithCursors; + if (!record.metadata) { + continue; // this is a new document, no need to sync the cursors + } - for (const doc of documentsWithCursors) { - const readContent = await this.fileOperations.read( - doc.relative_path - ); - const record = this.database.getLatestDocumentByRelativePath( - doc.relative_path - ); - if (record?.metadata?.hash !== hash(readContent)) { - doc.vault_update_id = null; - } - } + documentsWithCursors.push({ + relative_path: relativePath, + document_id: record.documentId, + vault_update_id: record.metadata.parentVersionId, + cursors: cursors.map(({ start, end }) => ({ + start: Math.min(start, end), + end: Math.max(start, end) + })) // the client might send directional selections + }); + } - if ( - JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === - JSON.stringify(documentsWithCursors) - ) { - return; - } + if ( + JSON.stringify(this.lastLocalCursorState) === + JSON.stringify(documentsWithCursors) + ) { + // Caching step to avoid reading the edited files all the time + return; + } + this.lastLocalCursorState = documentsWithCursors; - this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; + for (const doc of documentsWithCursors) { + const readContent = await this.fileOperations.read( + doc.relative_path + ); + const record = this.database.getLatestDocumentByRelativePath( + doc.relative_path + ); + if (record?.metadata?.hash !== hash(readContent)) { + doc.vault_update_id = null; + } + } - this.webSocketManager.updateLocalCursors({ documentsWithCursors }); - } + if ( + JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === + JSON.stringify(documentsWithCursors) + ) { + return; + } - // The returned position may be accurate, if it matches the document version, or outdated, in which case - // the client has to heuristically guess it's current position based on the local edits. - public addRemoteCursorsUpdateListener( - listener: (cursors: MaybeOutdatedClientCursors[]) => unknown - ): void { - // CursorTracker registers its own event listener in the constructor so it must have been called before this - this.webSocketManager.addRemoteCursorsUpdateListener(async () => { - await this.updateLock.withLock(() => - listener(this.getRelevantAndPruneKnownClientCursors()) - ); - }); - } + this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; - private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { - const result: MaybeOutdatedClientCursors[] = []; - const included = new Set(); + this.webSocketManager.updateLocalCursors({ documentsWithCursors }); + } - const relevantCursors = []; - for (const clientCursors of [...this.knownRemoteCursors].reverse()) { - if (included.has(clientCursors.deviceId)) { - continue; - } + public reset(): void { + this.knownRemoteCursors = []; + this.lastLocalCursorState = []; + this.lastLocalCursorStateWithoutDirtyDocuments = []; + this.updateLock.reset(); + } - if (clientCursors.upToDateness == DocumentUpToDateness.Later) { - continue; - } + private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { + const result: MaybeOutdatedClientCursors[] = []; + const included = new Set(); - result.push({ - ...clientCursors, - isOutdated: - clientCursors.upToDateness == DocumentUpToDateness.Prior - }); + const relevantCursors = []; + for (const clientCursors of [...this.knownRemoteCursors].reverse()) { + if (included.has(clientCursors.deviceId)) { + continue; + } - included.add(clientCursors.deviceId); - relevantCursors.unshift(clientCursors); // to reverse order back to normal - } + if (clientCursors.upToDateness === DocumentUpToDateness.Later) { + continue; + } - this.knownRemoteCursors = relevantCursors; + result.push({ + ...clientCursors, + isOutdated: + clientCursors.upToDateness === DocumentUpToDateness.Prior + }); - return result; - } + included.add(clientCursors.deviceId); + relevantCursors.unshift(clientCursors); // to reverse order back to normal + } - // We store up-to-dateness on a per-client basis to simplify the implementation. - // An individual client won't have too many documents open at once, so this is a reasonable trade-off. - private async getDocumentsUpToDateness( - clientCursor: ClientCursors - ): Promise { - const results = []; - for (const document of clientCursor.documentsWithCursors) { - results.push(await this.getDocumentUpToDateness(document)); - } + this.knownRemoteCursors = relevantCursors; - if ( - results.every((result) => result === DocumentUpToDateness.UpToDate) - ) { - return DocumentUpToDateness.UpToDate; - } + return result; + } - if ( - results.every( - (result) => - result === DocumentUpToDateness.UpToDate || - result === DocumentUpToDateness.Prior - ) - ) { - return DocumentUpToDateness.Prior; - } + // We store up-to-dateness on a per-client basis to simplify the implementation. + // An individual client won't have too many documents open at once, so this is a reasonable trade-off. + private async getDocumentsUpToDateness( + clientCursor: ClientCursors + ): Promise { + const results = []; + for (const document of clientCursor.documentsWithCursors) { + results.push(await this.getDocumentUpToDateness(document)); + } - return DocumentUpToDateness.Later; - } + if ( + results.every((result) => result === DocumentUpToDateness.UpToDate) + ) { + return DocumentUpToDateness.UpToDate; + } - private async getDocumentUpToDateness( - document: DocumentWithCursors - ): Promise { - const record = this.database.getLatestDocumentByRelativePath( - document.relative_path - ); + if ( + results.every( + (result) => + result === DocumentUpToDateness.UpToDate || + result === DocumentUpToDateness.Prior + ) + ) { + return DocumentUpToDateness.Prior; + } - if (!record) { - // the document of the cursor must be from the future - return DocumentUpToDateness.Later; - } + return DocumentUpToDateness.Later; + } - if ( - (record.metadata?.parentVersionId ?? 0) < - (document.vault_update_id ?? 0) - ) { - return DocumentUpToDateness.Later; - } else if ( - (document.vault_update_id ?? 0) < - (record.metadata?.parentVersionId ?? 0) - ) { - // the document of the cursor must be from the past - return DocumentUpToDateness.Prior; - } + private async getDocumentUpToDateness( + document: DocumentWithCursors + ): Promise { + const record = this.database.getLatestDocumentByRelativePath( + document.relative_path + ); - const currentContent = await this.fileOperations.read( - document.relative_path - ); + if (!record) { + // the document of the cursor must be from the future + return DocumentUpToDateness.Later; + } - return this.database.getLatestDocumentByRelativePath( - document.relative_path - )?.metadata?.hash === hash(currentContent) - ? DocumentUpToDateness.UpToDate - : DocumentUpToDateness.Prior; - } + if ( + (record.metadata?.parentVersionId ?? 0) < + (document.vault_update_id ?? 0) + ) { + return DocumentUpToDateness.Later; + } else if ( + (document.vault_update_id ?? 0) < + (record.metadata?.parentVersionId ?? 0) + ) { + // the document of the cursor must be from the past + return DocumentUpToDateness.Prior; + } + + const currentContent = await this.fileOperations.read( + document.relative_path + ); + + return this.database.getLatestDocumentByRelativePath( + document.relative_path + )?.metadata?.hash === hash(currentContent) + ? DocumentUpToDateness.UpToDate + : DocumentUpToDateness.Prior; + } } diff --git a/frontend/sync-client/src/sync-operations/file-change-notifier.ts b/frontend/sync-client/src/sync-operations/file-change-notifier.ts index 8a7af66c..d1e49d62 100644 --- a/frontend/sync-client/src/sync-operations/file-change-notifier.ts +++ b/frontend/sync-client/src/sync-operations/file-change-notifier.ts @@ -1,15 +1,12 @@ import type { RelativePath } from "../persistence/database"; +import { EventListeners } from "../utils/data-structures/event-listeners"; export class FileChangeNotifier { - private readonly listeners: ((filePath: RelativePath) => unknown)[] = []; + public readonly onFileChanged = new EventListeners< + (filePath: RelativePath) => unknown + >(); - public addFileChangeListener( - listener: (filePath: RelativePath) => unknown - ): void { - this.listeners.push(listener); - } - - public notifyOfFileChange(filePath: RelativePath): void { - this.listeners.forEach((listener) => listener(filePath)); - } + public notifyOfFileChange(filePath: RelativePath): void { + this.onFileChanged.trigger(filePath); + } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 03041a36..71dedd85 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -1,8 +1,8 @@ import type { - Database, - DocumentId, - DocumentRecord, - RelativePath + Database, + DocumentId, + DocumentRecord, + RelativePath } from "../persistence/database"; import type { SyncService } from "../services/sync-service"; import type { Logger } from "../tracing/logger"; @@ -15,445 +15,508 @@ import { findMatchingFile } from "../utils/find-matching-file"; import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import { createPromise } from "../utils/create-promise"; import { SyncResetError } from "../services/sync-reset-error"; -import { Locks } from "../utils/locks"; +import { Locks } from "../utils/data-structures/locks"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; +import type { WebSocketManager } from "../services/websocket-manager"; +import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage"; +import { awaitAll } from "../utils/await-all"; +import { EventListeners } from "../utils/data-structures/event-listeners"; export class Syncer { - private readonly remoteDocumentsLock: Locks; - private readonly remainingOperationsListeners: (( - remainingOperations: number - ) => unknown)[] = []; - private readonly syncQueue: PQueue; + public readonly onRemainingOperationsCountChanged = new EventListeners< + (remainingOperations: number) => unknown + >(); - private runningScheduleSyncForOfflineChanges: Promise | undefined; + private readonly remoteDocumentsLock: Locks; - public constructor( - private readonly logger: Logger, - private readonly database: Database, - settings: Settings, - private readonly syncService: SyncService, - private readonly operations: FileOperations, - private readonly internalSyncer: UnrestrictedSyncer - ) { - this.syncQueue = new PQueue({ - concurrency: settings.getSettings().syncConcurrency - }); + // FIFO to limit the number of concurrent sync operations + private readonly syncQueue: PQueue; - this.remoteDocumentsLock = new Locks(this.logger); + private _isFirstSyncComplete = false; + private runningScheduleSyncForOfflineChanges: Promise | undefined; + private previousRemainingOperationsCount = 0; - settings.addOnSettingsChangeListener((newSettings, oldSettings) => { - if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { - this.syncQueue.concurrency = newSettings.syncConcurrency; - } - }); + public constructor( + private readonly deviceId: string, + private readonly logger: Logger, + private readonly database: Database, + private readonly settings: Settings, + private readonly syncService: SyncService, + private readonly webSocketManager: WebSocketManager, + private readonly operations: FileOperations, + private readonly internalSyncer: UnrestrictedSyncer + ) { + this.syncQueue = new PQueue({ + concurrency: settings.getSettings().syncConcurrency + }); - this.syncQueue.on("active", () => { - this.remainingOperationsListeners.forEach((listener) => { - listener(this.syncQueue.size); - }); - }); - } + this.remoteDocumentsLock = new Locks(this.logger); - public addRemainingOperationsListener( - listener: (remainingOperations: number) => unknown - ): void { - this.remainingOperationsListeners.push(listener); - } + settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { + this.syncQueue.concurrency = newSettings.syncConcurrency; + } + }); - public async syncLocallyCreatedFile( - relativePath: RelativePath - ): Promise { - if ( - this.database.getLatestDocumentByRelativePath(relativePath) - ?.isDeleted === false - ) { - this.logger.debug( - `Document ${relativePath} already exists in the database, skipping` - ); - return; - } + this.syncQueue.on("active", () => { + if (this.previousRemainingOperationsCount !== this.syncQueue.size) { + this.previousRemainingOperationsCount = this.syncQueue.size; + this.onRemainingOperationsCountChanged.trigger( + this.syncQueue.size + ); + } + }); - const [promise, resolve, reject] = createPromise(); + this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => { + if (isConnected) { + // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message + this.sendHandshakeMessage(); + } + }); + this.webSocketManager.onRemoteVaultUpdateReceived.add( + this.syncRemotelyUpdatedFile.bind(this) + ); + } - const id = uuidv4(); - const document = this.database.createNewPendingDocument( - id, - relativePath, - promise - ); + public get isFirstSyncComplete(): boolean { + return this._isFirstSyncComplete; + } - this.logger.debug( - `Creating new pending document ${relativePath} with id ${id}` - ); + public async syncLocallyCreatedFile( + relativePath: RelativePath + ): Promise { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === false + ) { + this.logger.debug( + `Document ${relativePath} already exists in the database, skipping` + ); + return; + } - try { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document) - ); + const [promise, resolve, reject] = createPromise(); - resolve(); - } catch (e) { - reject(e); - } finally { - this.database.removeDocumentPromise(promise); - } - } + const id = uuidv4(); + const document = this.database.createNewPendingDocument( + id, + relativePath, + promise + ); - public async syncLocallyDeletedFile( - relativePath: RelativePath - ): Promise { - if ( - this.database.getLatestDocumentByRelativePath(relativePath) - ?.isDeleted === true - ) { - // This is must be a consequence of us deleting a file because of a remote update - // which triggered a local delete, so we don't need to do anything here. - this.logger.debug( - `Document ${relativePath} has already been markes as deleted, skipping` - ); - return; - } + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document) + ); - // We have to have a record of the delete in case there's an in-flight update for the same - // document which finishes after the delete has succeeded and would introduce a phantom metadata record. - this.database.delete(relativePath); + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } - const [promise, resolve, reject] = createPromise(); + public async syncLocallyDeletedFile( + relativePath: RelativePath + ): Promise { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === true + ) { + // This is must be a consequence of us deleting a file because of a remote update + // which triggered a local delete, so we don't need to do anything here. + this.logger.debug( + `Document ${relativePath} has already been markes as deleted, skipping` + ); + return; + } - const document = await this.database.getResolvedDocumentByRelativePath( - relativePath, - promise - ); + // We have to have a record of the delete in case there's an in-flight update for the same + // document which finishes after the delete has succeeded and would introduce a phantom metadata record. + this.database.delete(relativePath); - try { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document) - ); + const [promise, resolve, reject] = createPromise(); - resolve(); + const document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); - this.database.removeDocument(document); - } catch (e) { - reject(e); - } finally { - this.database.removeDocumentPromise(promise); - } - } + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document) + ); - public async syncLocallyUpdatedFile({ - oldPath, - relativePath - }: { - oldPath?: RelativePath; - relativePath: RelativePath; - }): Promise { - if (oldPath !== undefined) { - // We might have moved the document in the database before calling this method, - // in that case, we mustn't move it again. - if ( - this.database.getLatestDocumentByRelativePath(relativePath) === - undefined || - this.database.getLatestDocumentByRelativePath(relativePath) - ?.isDeleted === true - ) { - if (oldPath === relativePath) { - throw new Error( - `Old path and new path are the same: ${oldPath}` - ); - } + resolve(); - this.database.move(oldPath, relativePath); - } - } + this.database.removeDocument(document); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } - let document = - this.database.getLatestDocumentByRelativePath(relativePath); + public async syncLocallyUpdatedFile({ + oldPath, + relativePath + }: { + oldPath?: RelativePath; + relativePath: RelativePath; + }): Promise { + if (oldPath !== undefined) { + // We might have moved the document in the database before calling this method, + // in that case, we mustn't move it again. + if ( + this.database.getLatestDocumentByRelativePath(relativePath) === + undefined || + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === true + ) { + if (oldPath === relativePath) { + throw new Error( + `Old path and new path are the same: ${oldPath}` + ); + } - if ( - oldPath !== undefined && - document?.metadata?.remoteRelativePath === relativePath - ) { - this.logger.debug( - `Document ${relativePath} has been moved as a result of a remote update, skipping sync` - ); - return; - } + this.database.move(oldPath, relativePath); + } + } - if (document === undefined) { - this.logger.debug( - `Cannot find document ${relativePath} in the database, skipping` - ); - return; - } + let document = + this.database.getLatestDocumentByRelativePath(relativePath); - if (document.isDeleted) { - this.logger.debug( - `Document ${relativePath} has been deleted locally, skipping` - ); - return; - } + if ( + oldPath !== undefined && + document?.metadata?.remoteRelativePath === relativePath + ) { + this.logger.debug( + `Document ${relativePath} has been moved as a result of a remote update, skipping sync` + ); + return; + } - const [promise, resolve, reject] = createPromise(); + if (document === undefined) { + this.logger.debug( + `Cannot find document ${relativePath} in the database, skipping` + ); + return; + } - document = await this.database.getResolvedDocumentByRelativePath( - relativePath, - promise - ); + if (document.isDeleted) { + this.logger.debug( + `Document ${relativePath} has been deleted locally, skipping` + ); + return; + } - try { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({ - oldPath, - document - }) - ); + const [promise, resolve, reject] = createPromise(); - resolve(); - } catch (e) { - reject(e); - } finally { - this.database.removeDocumentPromise(promise); - } - } + document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); - public async scheduleSyncForOfflineChanges(): Promise { - if (this.runningScheduleSyncForOfflineChanges !== undefined) { - this.logger.debug("Uploading local changes is already in progress"); - return this.runningScheduleSyncForOfflineChanges; - } + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({ + oldPath, + document + }) + ); - try { - this.runningScheduleSyncForOfflineChanges = - this.internalScheduleSyncForOfflineChanges(); - await this.runningScheduleSyncForOfflineChanges; - this.logger.info(`All local changes have been applied remotely`); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - "Failed to apply local changes remotely due to a reset" - ); - return; - } - this.logger.error( - `Not all local changes have been applied remotely: ${e}` - ); - throw e; - } finally { - this.runningScheduleSyncForOfflineChanges = undefined; - } - } + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } - public async waitUntilFinished(): Promise { - await this.runningScheduleSyncForOfflineChanges; - return this.syncQueue.onEmpty(); - } + public async scheduleSyncForOfflineChanges(): Promise { + if (this.runningScheduleSyncForOfflineChanges !== undefined) { + this.logger.debug("Uploading local changes is already in progress"); + return this.runningScheduleSyncForOfflineChanges; + } - public async reset(): Promise { - await this.waitUntilFinished(); - } + try { + this.runningScheduleSyncForOfflineChanges = + this.internalScheduleSyncForOfflineChanges(); + await this.runningScheduleSyncForOfflineChanges; + this.logger.info(`All local changes have been applied remotely`); + } catch (e) { + if (e instanceof SyncResetError) { + this.logger.info( + "Failed to apply local changes remotely due to a reset" + ); + return; + } + this.logger.error( + `Not all local changes have been applied remotely: ${e}` + ); + throw e; + } finally { + this.runningScheduleSyncForOfflineChanges = undefined; + } + } - public async syncRemotelyUpdatedFile( - remoteVersion: DocumentVersionWithoutContent - ): Promise { - let document = this.database.getDocumentByDocumentId( - remoteVersion.documentId - ); + public async waitUntilFinished(): Promise { + await this.runningScheduleSyncForOfflineChanges; + await this.syncQueue.onIdle(); // Wait for queue to be empty and running tasks to finish + } - if (document === undefined) { - // Let's avoid the same documents getting created in parallel multiple times. - // There might be multiple tasks waiting for the lock - return this.remoteDocumentsLock.withLock( - remoteVersion.documentId, - async () => { - document = this.database.getDocumentByDocumentId( - remoteVersion.documentId - ); + public async syncRemotelyUpdatedFile( + message: WebSocketVaultUpdate + ): Promise { + try { + const handlerPromise = awaitAll( + message.documents.map(async (document) => + this.internalSyncRemotelyUpdatedFile(document) + ) + ); - // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` - if (document === undefined) { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( - remoteVersion - ) - ); - } else { - const [promise, resolve, reject] = createPromise(); + await handlerPromise; - document = - await this.database.getResolvedDocumentByRelativePath( - document.relativePath, - promise - ); + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) + ); + } - try { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( - remoteVersion, - document - ) - ); + this._isFirstSyncComplete = true; + } catch (e) { + this.logger.error(`Failed to sync remotely updated file: ${e}`); + } + } - resolve(); - } catch (e) { - reject(e); - } finally { - this.database.removeDocumentPromise(promise); - } - } + public reset(): void { + this._isFirstSyncComplete = false; + this.syncQueue.clear(); + this.remoteDocumentsLock.reset(); + this.runningScheduleSyncForOfflineChanges = undefined; + } - this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); - } - ); - } + private sendHandshakeMessage(): void { + const message: WebSocketClientMessage = { + type: "handshake", + deviceId: this.deviceId, + token: this.settings.getSettings().token, + lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() + }; + this.webSocketManager.sendHandshakeMessage(message); + } - // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` - const [promise, resolve, reject] = createPromise(); + private async internalSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent + ): Promise { + let document = this.database.getDocumentByDocumentId( + remoteVersion.documentId + ); - document = await this.database.getResolvedDocumentByRelativePath( - document.relativePath, - promise - ); + if (document === undefined) { + // Let's avoid the same documents getting created in parallel multiple times. + // There might be multiple tasks waiting for the lock + return this.remoteDocumentsLock.withLock( + remoteVersion.documentId, + async () => { + document = this.database.getDocumentByDocumentId( + remoteVersion.documentId + ); - try { - await this.syncQueue.add(async () => - this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( - remoteVersion, - document - ) - ); + // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` + if (document === undefined) { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion + ) + ); + } else { + const [promise, resolve, reject] = createPromise(); - resolve(); - } catch (e) { - reject(e); - } finally { - this.database.removeDocumentPromise(promise); - } + document = + await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); - this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); - } + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); - private async internalScheduleSyncForOfflineChanges(): Promise { - await this.createFakeDocumentsFromRemoteState(); + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } - const allLocalFiles = await this.operations.listFilesRecursively(); + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } + ); + } - let locallyPossiblyDeletedFiles: DocumentRecord[] = []; + // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` + const [promise, resolve, reject] = createPromise(); - for (const document of this.database.resolvedDocuments) { - if ( - !document.isDeleted && - !(await this.operations.exists(document.relativePath)) - ) { - locallyPossiblyDeletedFiles.push(document); - } - } + document = await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); - const updates = Promise.all( - allLocalFiles.map(async (relativePath) => { - if ( - this.database.getLatestDocumentByRelativePath(relativePath) - ?.metadata !== undefined - ) { - this.logger.debug( - `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` - ); + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); - return this.syncLocallyUpdatedFile({ - relativePath - }); - } + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } - // Perhaps the file has been moved; let's check by looking at the deleted files - const contentHash = await this.syncQueue.add(async () => { - const contentBytes = - await this.operations.read(relativePath); // this can throw FileNotFoundError - return hash(contentBytes); - }); + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } - if (contentHash == undefined) { - // The file was deleted before we had a chance to read it, no need to sync it here - return; - } + private async internalScheduleSyncForOfflineChanges(): Promise { + await this.createFakeDocumentsFromRemoteState(); - const originalFile = findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (originalFile !== undefined) { - // `originalFile` hasn't been deleted but it got moved instead - locallyPossiblyDeletedFiles = - locallyPossiblyDeletedFiles.filter( - (item) => - item.relativePath !== originalFile.relativePath - ); + const allLocalFiles = await this.operations.listFilesRecursively(); + this.logger.info( + `Scheduling sync for ${allLocalFiles.length} local files` + ); - this.logger.debug( - `Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` - ); + let locallyPossiblyDeletedFiles: DocumentRecord[] = []; - // We're outside of the pqueue, so we need to call the public wrapper - return this.syncLocallyUpdatedFile({ - oldPath: originalFile.relativePath, - relativePath - }); - } + for (const document of this.database.resolvedDocuments) { + if ( + !document.isDeleted && + !(await this.operations.exists(document.relativePath)) + ) { + locallyPossiblyDeletedFiles.push(document); + } + } - this.logger.debug( - `Document ${relativePath} not found in database, scheduling sync to create it` - ); - // We're outside of the pqueue, so we need to call the public wrapper - return this.syncLocallyCreatedFile(relativePath); - }) - ); + await awaitAll( + allLocalFiles.map(async (relativePath) => { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.metadata !== undefined + ) { + this.logger.debug( + `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` + ); - const deletes = Promise.all( - locallyPossiblyDeletedFiles.map(async ({ relativePath }) => { - this.logger.debug( - `Document ${relativePath} has been deleted locally, scheduling sync to delete it` - ); + return this.syncLocallyUpdatedFile({ + relativePath + }); + } - // We're outside of the pqueue, so we need to call the public wrapper - return this.syncLocallyDeletedFile(relativePath); - }) - ); + // Perhaps the file has been moved; let's check by looking at the deleted files + const contentHash = await this.syncQueue.add(async () => { + const contentBytes = + await this.operations.read(relativePath); // this can throw FileNotFoundError + return hash(contentBytes); + }); - await Promise.all([updates, deletes]); - } + if (contentHash == undefined) { + // The file was deleted before we had a chance to read it, no need to sync it here + return; + } - /** - * Create fake documents in the database for all files that are present locally - * and also exist remotely. This will stop the subequent syncs from duplicating - * the documents by creating the same documents from multiple clients. - */ - private async createFakeDocumentsFromRemoteState(): Promise { - if (this.database.getHasInitialSyncCompleted()) { - return; - } + const originalFile = findMatchingFile( + contentHash, + locallyPossiblyDeletedFiles + ); + if (originalFile !== undefined) { + // `originalFile` hasn't been deleted but it got moved instead + /* eslint-disable no-restricted-syntax -- Comparing by property, not direct equality */ + locallyPossiblyDeletedFiles = + locallyPossiblyDeletedFiles.filter( + (item) => + item.relativePath !== originalFile.relativePath + ); + /* eslint-enable no-restricted-syntax */ - const [allLocalFiles, remote] = await Promise.all([ - this.operations.listFilesRecursively(), - this.syncQueue.add(async () => this.syncService.getAll()) - ]); + this.logger.debug( + `Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` + ); - if (remote !== undefined) { - remote.latestDocuments - .filter( - (remoteDocument) => - allLocalFiles.includes(remoteDocument.relativePath) && - !remoteDocument.isDeleted && - this.database.getDocumentByDocumentId( - remoteDocument.documentId - ) === undefined - ) - .forEach((remoteDocument) => { - this.database.createNewEmptyDocument( - remoteDocument.documentId, - remoteDocument.vaultUpdateId, - remoteDocument.relativePath - ); - }); - } + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyUpdatedFile({ + oldPath: originalFile.relativePath, + relativePath + }); + } - this.database.setHasInitialSyncCompleted(true); - } + this.logger.debug( + `Document ${relativePath} not found in database, scheduling sync to create it` + ); + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyCreatedFile(relativePath); + }) + ); + + // this has to happen strictly after the previous awaitAll, as that one + // might have removed some of the documents from the list + await awaitAll( + locallyPossiblyDeletedFiles.map(async ({ relativePath }) => { + this.logger.debug( + `Document ${relativePath} has been deleted locally, scheduling sync to delete it` + ); + + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyDeletedFile(relativePath); + }) + ); + } + + /** + * Create fake documents in the database for all files that are present locally + * and also exist remotely. This will stop the subequent syncs from duplicating + * the documents by creating the same documents from multiple clients. + */ + private async createFakeDocumentsFromRemoteState(): Promise { + if (this.database.getHasInitialSyncCompleted()) { + return; + } + + const [allLocalFiles, remote] = await awaitAll([ + this.operations.listFilesRecursively(), + this.syncQueue.add(async () => this.syncService.getAll()) + ]); + + if (remote !== undefined) { + remote.latestDocuments + .filter( + (remoteDocument) => + allLocalFiles.includes(remoteDocument.relativePath) && + !remoteDocument.isDeleted && + this.database.getDocumentByDocumentId( + remoteDocument.documentId + ) === undefined + ) + .forEach((remoteDocument) => { + this.database.createNewEmptyDocument( + remoteDocument.documentId, + remoteDocument.vaultUpdateId, + remoteDocument.relativePath + ); + }); + } + + this.database.setHasInitialSyncCompleted(true); + } } diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index 0d0f45ef..e3964d30 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -1,23 +1,25 @@ import type { - Database, - DocumentRecord, - RelativePath + Database, + DocumentRecord, + RelativePath } from "../persistence/database"; +import { diff } from "reconcile-text"; import type { SyncService } from "../services/sync-service"; import type { Logger } from "../tracing/logger"; import type { - CommonHistoryEntry, - SyncCreateDetails, - SyncDeleteDetails, - SyncDetails, - SyncHistory, - SyncMovedDetails, - SyncUpdateDetails + CommonHistoryEntry, + SyncCreateDetails, + SyncDeleteDetails, + SyncDetails, + SyncHistory, + SyncMovedDetails, + SyncUpdateDetails } from "../tracing/sync-history"; import { SyncStatus, SyncType } from "../tracing/sync-history"; import { EMPTY_HASH, hash } from "../utils/hash"; -import { deserialize } from "../utils/deserialize"; + +import { base64ToBytes } from "byte-base64"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; import { createPromise } from "../utils/create-promise"; @@ -27,487 +29,568 @@ import { globsToRegexes } from "../utils/globs-to-regexes"; import type { DocumentVersion } from "../services/types/DocumentVersion"; import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; +import { isFileTypeMergable } from "../utils/is-file-type-mergable"; +import { isBinary } from "../utils/is-binary"; +import type { ServerConfig } from "../services/server-config"; export class UnrestrictedSyncer { - private ignorePatterns: RegExp[]; + private ignorePatterns: RegExp[]; - public constructor( - private readonly logger: Logger, - private readonly database: Database, - private readonly settings: Settings, - private readonly syncService: SyncService, - private readonly operations: FileOperations, - private readonly history: SyncHistory - ) { - this.ignorePatterns = globsToRegexes( - this.settings.getSettings().ignorePatterns, - this.logger - ); + public constructor( + private readonly logger: Logger, + private readonly database: Database, + private readonly settings: Settings, + private readonly syncService: SyncService, + private readonly operations: FileOperations, + private readonly history: SyncHistory, + private readonly contentCache: FixedSizeDocumentCache, + private readonly serverConfig: ServerConfig + ) { + this.ignorePatterns = globsToRegexes( + this.settings.getSettings().ignorePatterns, + this.logger + ); - this.settings.addOnSettingsChangeListener((newSettings) => { - this.ignorePatterns = globsToRegexes( - newSettings.ignorePatterns, - this.logger - ); - }); - } + this.settings.onSettingsChanged.add((newSettings) => { + this.ignorePatterns = globsToRegexes( + newSettings.ignorePatterns, + this.logger + ); + }); + } - public async unrestrictedSyncLocallyCreatedFile( - document: DocumentRecord - ): Promise { - const updateDetails: SyncCreateDetails = { - type: SyncType.CREATE, - relativePath: document.relativePath - }; + public async unrestrictedSyncLocallyCreatedFile( + document: DocumentRecord + ): Promise { + const updateDetails: SyncCreateDetails = { + type: SyncType.CREATE, + relativePath: document.relativePath + }; - return this.executeSync(updateDetails, async () => { - if (document.isDeleted) { - this.logger.debug( - `Document ${document.relativePath} has been already deleted, no need to create it` - ); - return; - } + return this.executeSync(updateDetails, async () => { + const originalRelativePath = document.relativePath; + if (document.isDeleted) { + this.logger.debug( + `Document ${originalRelativePath} has been already deleted, no need to create it` + ); + return; + } - const contentBytes = await this.operations.read( - document.relativePath - ); // this can throw FileNotFoundError - const contentHash = hash(contentBytes); + const contentBytes = + await this.operations.read(originalRelativePath); // this can throw FileNotFoundError + const contentHash = hash(contentBytes); - const response = await this.syncService.create({ - documentId: document.documentId, - relativePath: document.relativePath, - contentBytes - }); + const response = await this.syncService.create({ + documentId: document.documentId, + relativePath: originalRelativePath, + contentBytes + }); - this.database.updateDocumentMetadata( - { - parentVersionId: response.vaultUpdateId, - hash: contentHash, - remoteRelativePath: response.relativePath - }, - document - ); + // In case a document with the same name (but different ID) had existed remotely that we haven't known about + if (response.relativePath != originalRelativePath) { + this.logger.debug( + `Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally` + ); + await this.operations.move( + document.relativePath, + response.relativePath + ); // this can throw FileNotFoundError + } - this.database.addSeenUpdateId(response.vaultUpdateId); + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: updateDetails, - message: `Successfully uploaded locally created file` - }); - }); - } + this.database.addSeenUpdateId(response.vaultUpdateId); + await this.updateCache( + response.vaultUpdateId, + contentBytes, + response.relativePath + ); - public async unrestrictedSyncLocallyDeletedFile( - document: DocumentRecord - ): Promise { - const updateDetails: SyncDeleteDetails = { - type: SyncType.DELETE, - relativePath: document.relativePath - }; + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully uploaded locally created file` + }); + }); + } - await this.executeSync(updateDetails, async () => { - const response = await this.syncService.delete({ - documentId: document.documentId, - relativePath: document.relativePath - }); + public async unrestrictedSyncLocallyDeletedFile( + document: DocumentRecord + ): Promise { + const updateDetails: SyncDeleteDetails = { + type: SyncType.DELETE, + relativePath: document.relativePath + }; - this.database.updateDocumentMetadata( - { - parentVersionId: response.vaultUpdateId, - hash: EMPTY_HASH, - remoteRelativePath: document.relativePath - }, - document - ); + await this.executeSync(updateDetails, async () => { + const response = await this.syncService.delete({ + documentId: document.documentId, + relativePath: document.relativePath + }); - this.database.addSeenUpdateId(response.vaultUpdateId); + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: document.relativePath + }, + document + ); - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: updateDetails, - message: `Successfully deleted locally deleted file on the server`, - author: response.userId - }); - }); - } + this.database.addSeenUpdateId(response.vaultUpdateId); - public async unrestrictedSyncLocallyUpdatedFile({ - oldPath, - document, - // We use the same code path for both local and remote updates. We need to force the update - // if there are no local changes but we know that the remote version is newer. - force = false - }: { - oldPath?: RelativePath; - force?: boolean; - document: DocumentRecord; - }): Promise { - const updateDetails: SyncUpdateDetails | SyncMovedDetails = - oldPath !== undefined - ? { - type: SyncType.MOVE, - relativePath: document.relativePath, - movedFrom: oldPath - } - : { - type: SyncType.UPDATE, - relativePath: document.relativePath - }; + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully deleted locally deleted file on the server`, + author: response.userId + }); + }); + } - await this.executeSync(updateDetails, async () => { - const originalRelativePath = document.relativePath; + public async unrestrictedSyncLocallyUpdatedFile({ + oldPath, + document, + // We use the same code path for both local and remote updates. We need to force the update + // if there are no local changes but we know that the remote version is newer. + force = false + }: { + oldPath?: RelativePath; + force?: boolean; + document: DocumentRecord; + }): Promise { + const updateDetails: SyncUpdateDetails | SyncMovedDetails = + oldPath !== undefined + ? { + type: SyncType.MOVE, + relativePath: document.relativePath, + movedFrom: oldPath + } + : { + type: SyncType.UPDATE, + relativePath: document.relativePath + }; - if (document.isDeleted || document.metadata === undefined) { - this.logger.debug( - `Document ${document.relativePath} has been already deleted, no need to update it` - ); - return; - } + await this.executeSync(updateDetails, async () => { + const originalRelativePath = document.relativePath; - const contentBytes = await this.operations.read( - document.relativePath - ); // this can throw FileNotFoundError - let contentHash = hash(contentBytes); + if (document.isDeleted || document.metadata === undefined) { + this.logger.debug( + `Document ${document.relativePath} has been already deleted, no need to update it` + ); + return; + } - const areThereLocalChanges = !( - document.metadata.hash === contentHash && oldPath === undefined - ); + const contentBytes = await this.operations.read( + document.relativePath + ); // this can throw FileNotFoundError + let contentHash = hash(contentBytes); - let response: DocumentVersion | DocumentUpdateResponse | undefined = - undefined; + const areThereLocalChanges = !( + document.metadata.hash === contentHash && oldPath === undefined + ); - if (areThereLocalChanges) { - response = await this.syncService.put({ - documentId: document.documentId, - parentVersionId: document.metadata.parentVersionId, - relativePath: document.relativePath, - contentBytes - }); - } else { - if (!force) { - this.logger.debug( - `File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync` - ); - return; - } + let response: DocumentVersion | DocumentUpdateResponse | undefined = + undefined; - response = await this.syncService.get({ - documentId: document.documentId - }); - } + if (areThereLocalChanges) { + const isText = + !isBinary(contentBytes) && + isFileTypeMergable( + document.relativePath, + (await this.serverConfig.getConfig()) + .mergeableFileExtensions + ); + const cachedVersion = this.contentCache.get( + document.metadata.parentVersionId + ); - // `document` is mutable and reflects the latest state in the local database - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (document.isDeleted) { - this.logger.info( - `Document ${document.relativePath} has been deleted before we could finish updating it` - ); - this.database.addSeenUpdateId(response.vaultUpdateId); - return; - } + response = + isText && cachedVersion !== undefined + ? await this.syncService.putText({ + documentId: document.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + content: diff( + new TextDecoder().decode(cachedVersion), + new TextDecoder().decode(contentBytes) + ) + }) + : await this.syncService.putBinary({ + documentId: document.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + contentBytes + }); + } else { + if (!force) { + this.logger.debug( + `File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync` + ); + return; + } - if ( - // `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match - // the latest versions so we still need to update the local versions to turn the fakes into real metadata. - document.metadata.parentVersionId > response.vaultUpdateId - ) { - this.logger.debug( - `Document ${document.relativePath} is already more up to date than the fetched version` - ); - this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through - return; - } + response = await this.syncService.get({ + documentId: document.documentId + }); + } - if (response.isDeleted) { - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath: document.relativePath - }, - message: - "File has been deleted remotely, so we deleted it locally", - author: response.userId - }); + // `document` is mutable and reflects the latest state in the local database + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (document.isDeleted) { + this.logger.info( + `Document ${document.relativePath} has been deleted before we could finish updating it` + ); + this.database.addSeenUpdateId(response.vaultUpdateId); + return; + } - this.database.delete(document.relativePath); - this.database.updateDocumentMetadata( - { - parentVersionId: response.vaultUpdateId, - hash: EMPTY_HASH, - remoteRelativePath: response.relativePath - }, - document - ); + if ( + // `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match + // the latest versions so we still need to update the local versions to turn the fakes into real metadata. + document.metadata.parentVersionId > response.vaultUpdateId + ) { + this.logger.debug( + `Document ${document.relativePath} is already more up to date than the fetched version` + ); + this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through + return; + } - await this.operations.delete(document.relativePath); + if (response.isDeleted) { + return this.applyRemoteDeleteLocally(document, response); + } - this.database.addSeenUpdateId(response.vaultUpdateId); + let actualPath = document.relativePath; - return; - } + if (response.relativePath != originalRelativePath) { + actualPath = response.relativePath; + // Make sure to update the remote relative path to avoid uploading + // the file as a result of this filesystem event. + document.metadata.remoteRelativePath = response.relativePath; + await this.operations.move( + document.relativePath, + response.relativePath + ); // this can throw FileNotFoundError + } - let actualPath = document.relativePath; + if (!("type" in response) || response.type === "MergingUpdate") { + const responseBytes = base64ToBytes(response.contentBase64); + contentHash = hash(responseBytes); - if (response.relativePath != originalRelativePath) { - actualPath = response.relativePath; - // Make sure to update the remote relative path to avoid uploading - // the file as a result of this filesystem event. - document.metadata.remoteRelativePath = response.relativePath; - await this.operations.move( - document.relativePath, - response.relativePath - ); // this can throw FileNotFoundError - } + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + await this.operations.write( + actualPath, + contentBytes, + responseBytes + ); + await this.updateCache( + response.vaultUpdateId, + responseBytes, + actualPath + ); - if (!("type" in response) || response.type === "MergingUpdate") { - const responseBytes = deserialize(response.contentBase64); - contentHash = hash(responseBytes); + if (!force) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `The file we updated had been updated remotely, so we downloaded the merged version` + }); + } + } else { + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + await this.updateCache( + response.vaultUpdateId, + contentBytes, + actualPath + ); + } - this.database.updateDocumentMetadata( - { - parentVersionId: response.vaultUpdateId, - hash: contentHash, - remoteRelativePath: response.relativePath - }, - document - ); + this.database.addSeenUpdateId(response.vaultUpdateId); - await this.operations.write( - actualPath, - contentBytes, - responseBytes - ); + const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = + oldPath !== undefined || + response.relativePath != originalRelativePath + ? { + type: SyncType.MOVE, + relativePath: response.relativePath, + movedFrom: originalRelativePath + } + : { + type: SyncType.UPDATE, + relativePath: response.relativePath + }; - if (!force) { - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: updateDetails, - message: `The file we updated had been updated remotely, so we downloaded the merged version` - }); - } - } else { - this.database.updateDocumentMetadata( - { - parentVersionId: response.vaultUpdateId, - hash: contentHash, - remoteRelativePath: response.relativePath - }, - document - ); - } + if (areThereLocalChanges) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: actualUpdateDetails, + message: `Successfully uploaded locally updated file to the server`, + author: response.userId + }); + } else { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: actualUpdateDetails, + message: `Successfully downloaded remotely updated file from the server`, + author: response.userId, + timestamp: new Date(response.updatedDate) + }); + } + }); + } - this.database.addSeenUpdateId(response.vaultUpdateId); + public async unrestrictedSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent, + document?: DocumentRecord + ): Promise { + const updateDetails: SyncCreateDetails = { + type: SyncType.CREATE, + relativePath: remoteVersion.relativePath + }; - const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = - oldPath !== undefined || - response.relativePath != originalRelativePath - ? { - type: SyncType.MOVE, - relativePath: response.relativePath, - movedFrom: originalRelativePath - } - : { - type: SyncType.UPDATE, - relativePath: response.relativePath - }; + await this.executeSync(updateDetails, async () => { + if (document?.metadata !== undefined) { + // If the file exists locally, let's pretend the user has updated it + // and deal with remote update/deletion within `unrestrictedSyncLocallyUpdatedFile` + if ( + document.metadata.parentVersionId >= + remoteVersion.vaultUpdateId + ) { + this.logger.debug( + `Document ${remoteVersion.relativePath} is already at least as up to date as the fetched version` + ); - if (areThereLocalChanges) { - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: actualUpdateDetails, - message: `Successfully uploaded locally updated file to the server`, - author: response.userId - }); - } else { - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: actualUpdateDetails, - message: `Successfully downloaded remotely updated file from the server`, - author: response.userId - }); - } - }); - } + return; + } - public async unrestrictedSyncRemotelyUpdatedFile( - remoteVersion: DocumentVersionWithoutContent, - document?: DocumentRecord - ): Promise { - const updateDetails: SyncCreateDetails = { - type: SyncType.CREATE, - relativePath: remoteVersion.relativePath - }; + return this.unrestrictedSyncLocallyUpdatedFile({ + document, + force: true + }); + } else if (remoteVersion.isDeleted) { + // Either the document hasn't made it to us before and therefore we don't need to delete it, + // or we already have it, in which case the preceeding if would've dealt with it + this.logger.debug( + `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` + ); + return; + } - await this.executeSync(updateDetails, async () => { - if (document?.metadata !== undefined) { - // If the file exists locally, let's pretend the user has updated it - // and deal with remote update/deletion within `unrestrictedSyncLocallyUpdatedFile` - if ( - document.metadata.parentVersionId >= - remoteVersion.vaultUpdateId - ) { - this.logger.debug( - `Document ${remoteVersion.relativePath} is already at least as up to date as the fetched version` - ); + // Don't download oversized files + const historyEntryForSkippedOversizedFile = + this.getHistoryEntryForSkippedOversizedFile( + remoteVersion.contentSize, + remoteVersion.relativePath + ); + if (historyEntryForSkippedOversizedFile !== undefined) { + this.history.addHistoryEntry( + historyEntryForSkippedOversizedFile + ); + return; + } - return; - } + const contentBytes = + await this.syncService.getDocumentVersionContent({ + documentId: remoteVersion.documentId, + vaultUpdateId: remoteVersion.vaultUpdateId + }); - return this.unrestrictedSyncLocallyUpdatedFile({ - document, - force: true - }); - } else if (remoteVersion.isDeleted) { - // Either the document hasn't made it to us before and therefore we don't need to delete it, - // or we already have it, in which case the preceeding if would've dealt with it - this.logger.debug( - `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` - ); - return; - } + // We're trying to create an entirely new document that didn't exist locally + document = this.database.getDocumentByDocumentId( + remoteVersion.documentId + ); + // It can happen that a concurrent sync operation has already created the document, so we can bail here + if (document !== undefined) { + this.logger.debug( + `Document ${remoteVersion.relativePath} has already been created locally, no need to create it again` + ); + return; + } - // Don't download oversized files - const historyEntryForSkippedOversizedFile = - this.getHistoryEntryForSkippedOversizedFile( - remoteVersion.contentSize, - remoteVersion.relativePath - ); - if (historyEntryForSkippedOversizedFile !== undefined) { - this.history.addHistoryEntry( - historyEntryForSkippedOversizedFile - ); - return; - } + await this.operations.ensureClearPath(remoteVersion.relativePath); - const content = ( - await this.syncService.get({ - documentId: remoteVersion.documentId - }) - ).contentBase64; + const [promise, resolve] = createPromise(); + this.database.updateDocumentMetadata( + { + parentVersionId: remoteVersion.vaultUpdateId, + hash: hash(contentBytes), + remoteRelativePath: remoteVersion.relativePath + }, + this.database.createNewPendingDocument( + remoteVersion.documentId, + remoteVersion.relativePath, + promise + ) + ); - // We're trying to create an entirely new document that didn't exist locally - document = this.database.getDocumentByDocumentId( - remoteVersion.documentId - ); - // It can happen that a concurrent sync operation has already created the document, so we can bail here - if (document !== undefined) { - this.logger.debug( - `Document ${remoteVersion.relativePath} has already been created locally, no need to create it again` - ); - return; - } + await this.operations.create( + remoteVersion.relativePath, + contentBytes + ); + await this.updateCache( + remoteVersion.vaultUpdateId, + contentBytes, + remoteVersion.relativePath + ); - const contentBytes = deserialize(content); + resolve(); + this.database.removeDocumentPromise(promise); - await this.operations.ensureClearPath(remoteVersion.relativePath); + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully downloaded remote file which hadn't existed locally`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + }); + } - const [promise, resolve] = createPromise(); - this.database.updateDocumentMetadata( - { - parentVersionId: remoteVersion.vaultUpdateId, - hash: hash(contentBytes), - remoteRelativePath: remoteVersion.relativePath - }, - this.database.createNewPendingDocument( - remoteVersion.documentId, - remoteVersion.relativePath, - promise - ) - ); + public async executeSync( + details: SyncDetails, + fn: () => Promise + ): Promise { + for (const pattern of this.ignorePatterns) { + if (pattern.test(details.relativePath)) { + this.logger.debug( + `File '${details.relativePath}' is ignored by the ignore pattern: ${pattern}` + ); + return; // bail without SKIPPED status because we were told to ignore this file and we shouldn't clutter up the history + } + } - await this.operations.create( - remoteVersion.relativePath, - contentBytes - ); + try { + // Only check the size of files which already exist locally. + if (await this.operations.exists(details.relativePath)) { + const sizeInBytes = await this.operations.getFileSize( + details.relativePath + ); + const historyEntryForSkippedOversizedFile = + this.getHistoryEntryForSkippedOversizedFile( + sizeInBytes, + details.relativePath + ); + if (historyEntryForSkippedOversizedFile !== undefined) { + this.history.addHistoryEntry( + historyEntryForSkippedOversizedFile + ); + return; + } + } - resolve(); - this.database.removeDocumentPromise(promise); + return await fn(); + } catch (e) { + if (e instanceof FileNotFoundError) { + // A subsequent sync operation must have been creating to deal with this + this.logger.info( + `Skiping file '${details.relativePath}' because it no longer exists when trying to ${details.type.toLocaleLowerCase()} it` + ); + return; + } + if (e instanceof SyncResetError) { + this.logger.info( + `Interrupting sync operation because of a reset` + ); + return; + } else { + this.history.addHistoryEntry({ + status: SyncStatus.ERROR, + details, + message: `Failed to sync file '${details.relativePath}' because of ${e} when trying to ${details.type.toLocaleLowerCase()} it` + }); + throw e; + } + } + } - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: updateDetails, - message: `Successfully downloaded remote file which hadn't existed locally`, - author: remoteVersion.userId - }); - }); - } + private getHistoryEntryForSkippedOversizedFile( + sizeInBytes: number, + relativePath: RelativePath + ): CommonHistoryEntry | undefined { + const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); + const { maxFileSizeMB } = this.settings.getSettings(); + if (sizeInMB > maxFileSizeMB) { + return { + status: SyncStatus.SKIPPED, + details: { + type: SyncType.SKIPPED, + relativePath + }, + message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ + maxFileSizeMB + } MB` + }; + } + } - public async executeSync( - details: SyncDetails, - fn: () => Promise - ): Promise { - for (const pattern of this.ignorePatterns) { - if (pattern.test(details.relativePath)) { - this.logger.debug( - `File '${details.relativePath}' is ignored by the ignore pattern: ${pattern}` - ); - return; // bail without SKIPPED status because we were told to ignore this file and we shouldn't clutter up the history - } - } + private async updateCache( + updateId: number, + contentBytes: Uint8Array, + filePath: RelativePath + ): Promise { + if ( + isFileTypeMergable( + filePath, + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) && + !isBinary(contentBytes) + ) { + this.contentCache.put(updateId, contentBytes); + } + } - try { - // Only check the size of files which already exist locally. - if (await this.operations.exists(details.relativePath)) { - const sizeInBytes = await this.operations.getFileSize( - details.relativePath - ); - const historyEntryForSkippedOversizedFile = - this.getHistoryEntryForSkippedOversizedFile( - sizeInBytes, - details.relativePath - ); - if (historyEntryForSkippedOversizedFile !== undefined) { - this.history.addHistoryEntry( - historyEntryForSkippedOversizedFile - ); - return; - } - } + private async applyRemoteDeleteLocally( + document: DocumentRecord, + response: DocumentVersion | DocumentUpdateResponse + ): Promise { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.DELETE, + relativePath: document.relativePath + }, + message: "File has been deleted remotely, so we deleted it locally", + author: response.userId, + timestamp: new Date(response.updatedDate) + }); - return await fn(); - } catch (e) { - if (e instanceof FileNotFoundError) { - // A subsequent sync operation must have been creating to deal with this - this.logger.info( - `Skiping file '${details.relativePath}' because it no longer exists when trying to ${details.type.toLocaleLowerCase()} it` - ); - return; - } - if (e instanceof SyncResetError) { - this.logger.info( - `Interrupting sync operation because of a reset` - ); - return; - } else { - this.history.addHistoryEntry({ - status: SyncStatus.ERROR, - details, - message: `Failed to sync file '${details.relativePath}' because of ${e} when trying to ${details.type.toLocaleLowerCase()} it` - }); - throw e; - } - } - } + this.database.delete(document.relativePath); + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: response.relativePath + }, + document + ); - private getHistoryEntryForSkippedOversizedFile( - sizeInBytes: number, - relativePath: RelativePath - ): CommonHistoryEntry | undefined { - const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); - const { maxFileSizeMB } = this.settings.getSettings(); - if (sizeInMB > maxFileSizeMB) { - return { - status: SyncStatus.SKIPPED, - details: { - type: SyncType.SKIPPED, - relativePath - }, - message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ - maxFileSizeMB - } MB` - }; - } - } + await this.operations.delete(document.relativePath); + + this.database.addSeenUpdateId(response.vaultUpdateId); + } } diff --git a/frontend/sync-client/src/tracing/logger.ts b/frontend/sync-client/src/tracing/logger.ts index cf39e4de..6d544fbc 100644 --- a/frontend/sync-client/src/tracing/logger.ts +++ b/frontend/sync-client/src/tracing/logger.ts @@ -1,79 +1,72 @@ +import { MAX_LOG_MESSAGE_COUNT } from "../consts"; +import { EventListeners } from "../utils/data-structures/event-listeners"; + export enum LogLevel { - DEBUG = "DEBUG", - INFO = "INFO", - WARNING = "WARNING", - ERROR = "ERROR" + DEBUG = "DEBUG", + INFO = "INFO", + WARNING = "WARNING", + ERROR = "ERROR" } const LOG_LEVEL_ORDER = { - [LogLevel.DEBUG]: 0, - [LogLevel.INFO]: 1, - [LogLevel.WARNING]: 2, - [LogLevel.ERROR]: 3 + [LogLevel.DEBUG]: 0, + [LogLevel.INFO]: 1, + [LogLevel.WARNING]: 2, + [LogLevel.ERROR]: 3 }; export class LogLine { - public timestamp = new Date(); - public constructor( - public level: LogLevel, - public message: string - ) {} + public timestamp = new Date(); + public constructor( + public level: LogLevel, + public message: string + ) {} } export class Logger { - private static readonly MAX_MESSAGES = 100000; - private readonly messages: LogLine[] = []; - private readonly onMessageListeners: ((message: LogLine) => unknown)[] = []; + public readonly onLogEmitted = new EventListeners< + (message: LogLine) => unknown + >(); - public constructor( - ...onMessageListeners: ((message: LogLine) => unknown)[] - ) { - this.onMessageListeners = onMessageListeners; - } + private readonly messages: LogLine[] = []; - public debug(message: string): void { - this.pushMessage(message, LogLevel.DEBUG); - } + public debug(message: string): void { + this.pushMessage(message, LogLevel.DEBUG); + } - public info(message: string): void { - this.pushMessage(message, LogLevel.INFO); - } + public info(message: string): void { + this.pushMessage(message, LogLevel.INFO); + } - public warn(message: string): void { - this.pushMessage(message, LogLevel.WARNING); - } + public warn(message: string): void { + this.pushMessage(message, LogLevel.WARNING); + } - public error(message: string): void { - this.pushMessage(message, LogLevel.ERROR); - } + public error(message: string): void { + this.pushMessage(message, LogLevel.ERROR); + } - public getMessages(mininumSeverity: LogLevel): LogLine[] { - return this.messages.filter( - (message) => - LOG_LEVEL_ORDER[message.level] >= - LOG_LEVEL_ORDER[mininumSeverity] - ); - } + public getMessages(mininumSeverity: LogLevel): LogLine[] { + return this.messages.filter( + (message) => + LOG_LEVEL_ORDER[message.level] >= + LOG_LEVEL_ORDER[mininumSeverity] + ); + } - public addOnMessageListener(listener: (message: LogLine) => unknown): void { - this.onMessageListeners.push(listener); - } + public reset(): void { + this.messages.length = 0; + this.debug("Logger has been reset"); + } - public reset(): void { - this.messages.length = 0; - this.debug("Logger has been reset"); - } + private pushMessage(message: string, level: LogLevel): void { + const logLine = new LogLine(level, message); + this.messages.push(logLine); - private pushMessage(message: string, level: LogLevel): void { - const logLine = new LogLine(level, message); - this.messages.push(logLine); + while (this.messages.length > MAX_LOG_MESSAGE_COUNT) { + this.messages.shift(); + } - while (this.messages.length > Logger.MAX_MESSAGES) { - this.messages.shift(); - } - - this.onMessageListeners.forEach((listener) => { - listener(logLine); - }); - } + this.onLogEmitted.trigger(logLine); + } } diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index 6890688b..31f77283 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -1,174 +1,167 @@ +import { + MAX_HISTORY_ENTRY_COUNT, + TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS +} from "../consts"; import type { RelativePath } from "../persistence/database"; import type { Logger } from "./logger"; +import { removeFromArray } from "../utils/remove-from-array"; +import { EventListeners } from "../utils/data-structures/event-listeners"; export interface SyncCreateDetails { - type: SyncType.CREATE; - relativePath: RelativePath; + type: SyncType.CREATE; + relativePath: RelativePath; } export interface SyncUpdateDetails { - type: SyncType.UPDATE; - relativePath: RelativePath; + type: SyncType.UPDATE; + relativePath: RelativePath; } export interface SyncMovedDetails { - type: SyncType.MOVE; - relativePath: RelativePath; - movedFrom: RelativePath; + type: SyncType.MOVE; + relativePath: RelativePath; + movedFrom: RelativePath; } export interface SyncDeleteDetails { - type: SyncType.DELETE; - relativePath: RelativePath; + type: SyncType.DELETE; + relativePath: RelativePath; } export interface SyncSkippedDetails { - type: SyncType.SKIPPED; - relativePath: RelativePath; + type: SyncType.SKIPPED; + relativePath: RelativePath; } export type SyncDetails = - | SyncCreateDetails - | SyncUpdateDetails - | SyncDeleteDetails - | SyncMovedDetails - | SyncSkippedDetails; + | SyncCreateDetails + | SyncUpdateDetails + | SyncDeleteDetails + | SyncMovedDetails + | SyncSkippedDetails; export interface CommonHistoryEntry { - status: SyncStatus; - message: string; - details: SyncDetails; - author?: string; + status: SyncStatus; + message: string; + details: SyncDetails; + author?: string; + timestamp?: Date; } export enum SyncType { - CREATE = "CREATE", - UPDATE = "UPDATE", - DELETE = "DELETE", - MOVE = "MOVE", - SKIPPED = "SKIPPED" + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", + MOVE = "MOVE", + SKIPPED = "SKIPPED" } export enum SyncStatus { - SUCCESS = "SUCCESS", - ERROR = "ERROR", - SKIPPED = "SKIPPED" + SUCCESS = "SUCCESS", + ERROR = "ERROR", + SKIPPED = "SKIPPED" } export type HistoryEntry = CommonHistoryEntry & { timestamp: Date }; export interface HistoryStats { - success: number; - error: number; + success: number; + error: number; } export class SyncHistory { - private static readonly MAX_ENTRIES = 5000; - private static readonly TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS = 60; + public readonly onHistoryUpdated = new EventListeners< + (status: HistoryStats) => unknown + >(); - private _entries: HistoryEntry[] = []; + private readonly _entries: HistoryEntry[] = []; - private readonly syncHistoryUpdateListeners: (( - status: HistoryStats - ) => unknown)[] = []; + private status: HistoryStats = { + success: 0, + error: 0 + }; - private status: HistoryStats = { - success: 0, - error: 0 - }; + public constructor(private readonly logger: Logger) {} - public constructor(private readonly logger: Logger) {} + public get entries(): readonly HistoryEntry[] { + return this._entries; + } - public get entries(): readonly HistoryEntry[] { - return this._entries; - } + /** + * Insert the entry at the beginning of the history list. If the entry + * already in the list, it will get moved to the beginning and updated. + * + * If the entry list is too long, the oldest entry will be removed. + */ + public addHistoryEntry(entry: CommonHistoryEntry): void { + const historyEntry = { + ...entry, + timestamp: entry.timestamp ?? new Date() + }; - /** - * Insert the entry at the beginning of the history list. If the entry - * already in the list, it will get moved to the beginning and updated. - * - * If the entry list is too long, the oldest entry will be removed. - */ - public addHistoryEntry(entry: CommonHistoryEntry): void { - const historyEntry = { - ...entry, - timestamp: new Date() - }; + const candidate = this.findSimilarRecentUpdateEntry(historyEntry); + if (candidate !== undefined) { + removeFromArray(this._entries, candidate); + } - const candidate = this.findSimilarRecentUpdateEntry(historyEntry); - if (candidate !== undefined) { - this._entries = this._entries.filter((e) => e !== candidate); - } + // Insert the entry at the beginning + this._entries.unshift(historyEntry); - // Insert the entry at the beginning - this._entries.unshift(historyEntry); + if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) { + this._entries.pop(); + } - if (this._entries.length > SyncHistory.MAX_ENTRIES) { - this._entries.pop(); - } + this.updateSuccessCount(historyEntry); + } - this.updateSuccessCount(historyEntry); - } + public reset(): void { + this._entries.length = 0; + this.status = { + success: 0, + error: 0 + }; + this.onHistoryUpdated.trigger(this.status); + } - public addSyncHistoryUpdateListener( - listener: (stats: HistoryStats) => unknown - ): void { - this.syncHistoryUpdateListeners.push(listener); - listener({ ...this.status }); - } + private findSimilarRecentUpdateEntry( + entry: HistoryEntry + ): HistoryEntry | undefined { + if (entry.details.type !== SyncType.UPDATE) { + return; + } - public reset(): void { - this._entries.length = 0; - this.status = { - success: 0, - error: 0 - }; - this.syncHistoryUpdateListeners.forEach((listener) => { - listener(this.status); - }); - } + const candidate = this._entries.find( + (e) => + e.details.type === SyncType.UPDATE && + e.details.relativePath === entry.details.relativePath + ); + if ( + candidate !== undefined && + (this._entries[0] === candidate || + candidate.timestamp.getTime() + + TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 > + entry.timestamp.getTime()) + ) { + return candidate; + } + } - private findSimilarRecentUpdateEntry( - entry: HistoryEntry - ): HistoryEntry | undefined { - if (entry.details.type !== SyncType.UPDATE) { - return; - } + private updateSuccessCount(entry: HistoryEntry): void { + const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`; + switch (entry.status) { + case SyncStatus.SUCCESS: + this.status.success++; + this.logger.info(`History entry: ${message}`); + break; + case SyncStatus.ERROR: + this.status.error++; + this.logger.error(`Cannot sync file: ${message}`); + break; + case SyncStatus.SKIPPED: + this.logger.warn(`Skipping file: ${message}`); + break; + } - const candidate = this._entries.find( - (e) => - e.details.type === SyncType.UPDATE && - e.details.relativePath === entry.details.relativePath - ); - if ( - candidate !== undefined && - (this._entries[0] === candidate || - candidate.timestamp.getTime() + - SyncHistory.TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS * 1000 > - entry.timestamp.getTime()) - ) { - return candidate; - } - } - - private updateSuccessCount(entry: HistoryEntry): void { - const message = `${entry.details.relativePath} - ${entry.message} (${entry.details.type.toLocaleLowerCase()})`; - switch (entry.status) { - case SyncStatus.SUCCESS: - this.status.success++; - this.logger.info(`History entry: ${message}`); - break; - case SyncStatus.ERROR: - this.status.error++; - this.logger.error(`Cannot sync file: ${message}`); - break; - case SyncStatus.SKIPPED: - this.logger.error(`Skipping file: ${message}`); - break; - } - - this.syncHistoryUpdateListeners.forEach((listener) => { - listener(this.status); - }); - } + this.onHistoryUpdated.trigger(this.status); + } } diff --git a/frontend/sync-client/src/types/document-sync-status.ts b/frontend/sync-client/src/types/document-sync-status.ts index 07a0e801..501469e5 100644 --- a/frontend/sync-client/src/types/document-sync-status.ts +++ b/frontend/sync-client/src/types/document-sync-status.ts @@ -1,5 +1,5 @@ export enum DocumentSyncStatus { - UP_TO_DATE = "UP_TO_DATE", - SYNCING = "SYNCING", - SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED" + UP_TO_DATE = "UP_TO_DATE", + SYNCING = "SYNCING", + SYNCING_IS_DISABLED = "SYNCING_IS_DISABLED" } diff --git a/frontend/sync-client/src/types/document-up-to-dateness.ts b/frontend/sync-client/src/types/document-up-to-dateness.ts index 2f93f9b4..df30f5eb 100644 --- a/frontend/sync-client/src/types/document-up-to-dateness.ts +++ b/frontend/sync-client/src/types/document-up-to-dateness.ts @@ -1,5 +1,5 @@ export enum DocumentUpToDateness { - UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is - Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document. - Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally. + UpToDate = "UpToDate", // easiest case, the client can just show the cursors as-is + Prior = "Prior", // The cursors are outdated, so the client has to guess the cursor positions based on local updates. This is only possible if this client's cursor has once been up-to-date in a given document. + Later = "Later" // The cursors are from a future version of a document, there's no way we can accuratly show them locally. } diff --git a/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts b/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts index e062f84e..4793b872 100644 --- a/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts +++ b/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts @@ -1,5 +1,5 @@ import type { ClientCursors } from "../services/types/ClientCursors"; export interface MaybeOutdatedClientCursors extends ClientCursors { - isOutdated: boolean; + isOutdated: boolean; } diff --git a/frontend/sync-client/src/types/network-connection-status.ts b/frontend/sync-client/src/types/network-connection-status.ts index fb93f5f5..bf876665 100644 --- a/frontend/sync-client/src/types/network-connection-status.ts +++ b/frontend/sync-client/src/types/network-connection-status.ts @@ -1,5 +1,5 @@ export interface NetworkConnectionStatus { - isSuccessful: boolean; - serverMessage: string; - isWebSocketConnected: boolean; + isSuccessful: boolean; + serverMessage: string; + isWebSocketConnected: boolean; } diff --git a/frontend/sync-client/src/utils/assert-set-contains-exactly.ts b/frontend/sync-client/src/utils/assert-set-contains-exactly.ts index 502dca03..5baf965e 100644 --- a/frontend/sync-client/src/utils/assert-set-contains-exactly.ts +++ b/frontend/sync-client/src/utils/assert-set-contains-exactly.ts @@ -1,13 +1,13 @@ import assert from "node:assert"; export function assertSetContainsExactly(set: Set, ...values: T[]): void { - assert.ok( - set.size === values.length && - Array.from(set).every((value) => values.includes(value)), - `Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from( - set - ) - .map((v) => '"' + v + '"') - .join(", ")}` - ); + assert.ok( + set.size === values.length && + Array.from(set).every((value) => values.includes(value)), + `Expected set to contain only ${values.map((v) => '"' + v + '"').join(", ")}, but it contained ${Array.from( + set + ) + .map((v) => '"' + v + '"') + .join(", ")}` + ); } diff --git a/frontend/sync-client/src/utils/await-all.test.ts b/frontend/sync-client/src/utils/await-all.test.ts new file mode 100644 index 00000000..09a22d89 --- /dev/null +++ b/frontend/sync-client/src/utils/await-all.test.ts @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { awaitAll } from "./await-all"; + +void test("awaitAll resolves promises of the same type", async () => { + const promises = [ + Promise.resolve(1), + Promise.resolve(2), + Promise.resolve(3) + ]; + + const results = await awaitAll(promises); + assert.deepStrictEqual(results, [1, 2, 3]); +}); + +void test("awaitAll resolves promises of different types", async () => { + const promises = [ + Promise.resolve("hello"), + Promise.resolve(42), + Promise.resolve(true) + ] as const; + + const results = await awaitAll(promises); + + // Type assertions to verify type inference + const str: string = results[0]; + const num: number = results[1]; + const bool: boolean = results[2]; + + assert.strictEqual(str, "hello"); + assert.strictEqual(num, 42); + assert.strictEqual(bool, true); +}); + +void test("awaitAll throws on first rejection", async () => { + const error = new Error("Test error"); + const promises = [ + Promise.resolve(1), + Promise.reject(error), + Promise.resolve(3) + ]; + + await assert.rejects(async () => { + await awaitAll(promises); + }, error); +}); + +void test("awaitAll works with async functions", async () => { + const asyncString = async (): Promise => "async"; + const asyncNumber = async (): Promise => 123; + + const results = await awaitAll([asyncString(), asyncNumber()]); + + assert.strictEqual(results[0], "async"); + assert.strictEqual(results[1], 123); +}); diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts new file mode 100644 index 00000000..9406a6b8 --- /dev/null +++ b/frontend/sync-client/src/utils/await-all.ts @@ -0,0 +1,25 @@ +type PromiseTuple = readonly [ + ...{ [K in keyof T]: Promise } +]; + +type ResolvedTuple = { + [K in keyof T]: T[K]; +}; + +export const awaitAll = async ( + promises: PromiseTuple +): Promise> => { + // eslint-disable-next-line no-restricted-properties + const result = await Promise.allSettled(promises); + for (const res of result) { + if (res.status === "rejected") { + throw res.reason; + } + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return result.map( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (res) => (res as PromiseFulfilledResult).value + ) as ResolvedTuple; +}; diff --git a/frontend/sync-client/src/utils/create-client-id.ts b/frontend/sync-client/src/utils/create-client-id.ts index 60143b75..cfa132da 100644 --- a/frontend/sync-client/src/utils/create-client-id.ts +++ b/frontend/sync-client/src/utils/create-client-id.ts @@ -1,15 +1,15 @@ import { v4 as uuidv4 } from "uuid"; export function createClientId(): string { - // @ts-expect-error, injected by webpack - const packageVersion = __CURRENT_VERSION__; // eslint-disable-line + // @ts-expect-error, injected by webpack + const packageVersion = __CURRENT_VERSION__; // eslint-disable-line - const platform = - typeof navigator !== "undefined" - ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated - : typeof process !== "undefined" - ? process.platform - : "unknown"; + const platform = + typeof navigator !== "undefined" + ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated + : typeof process !== "undefined" + ? process.platform + : "unknown"; - return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`; + return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`; } diff --git a/frontend/sync-client/src/utils/create-promise.ts b/frontend/sync-client/src/utils/create-promise.ts index 542a4013..a49196ee 100644 --- a/frontend/sync-client/src/utils/create-promise.ts +++ b/frontend/sync-client/src/utils/create-promise.ts @@ -1,25 +1,25 @@ type ResolveFunction = undefined extends T - ? (value?: T) => unknown - : (value: T) => unknown; + ? (value?: T) => unknown + : (value: T) => unknown; /** * A type-safe utility function to create a Promise with resolve and reject functions. * @returns A tuple containing a Promise, a resolve function, and a reject function. */ export function createPromise(): [ - Promise, - ResolveFunction, - (error: unknown) => unknown + Promise, + ResolveFunction, + (error: unknown) => unknown ] { - let resolve: undefined | ResolveFunction = undefined; - let reject: undefined | ((error: unknown) => unknown) = undefined; + let resolve: undefined | ResolveFunction = undefined; + let reject: undefined | ((error: unknown) => unknown) = undefined; - const creationPromise = new Promise( - (resolve_, reject_) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - ((resolve = resolve_ as ResolveFunction), (reject = reject_)) - ); + const creationPromise = new Promise( + (resolve_, reject_) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ((resolve = resolve_ as ResolveFunction), (reject = reject_)) + ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return [creationPromise, resolve!, reject!]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return [creationPromise, resolve!, reject!]; } diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts new file mode 100644 index 00000000..a5f0cc7c --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts @@ -0,0 +1,150 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { EventListeners } from "./event-listeners"; + +describe("EventListeners", () => { + it("should add & remove listeners", () => { + const listeners = new EventListeners<() => void>(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener = (): void => {}; + + listeners.add(listener); + + assert.strictEqual(listeners.count, 1); + + const removed = listeners.remove(listener); + assert.strictEqual(removed, true); + assert.strictEqual(listeners.count, 0); + }); + + it("should remove listeners using unsubscribe function", () => { + const listeners = new EventListeners<() => void>(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener = (): void => {}; + + const unsubscribe = listeners.add(listener); + unsubscribe(); + + assert.strictEqual(listeners.count, 0); + }); + + it("should return false when removing non-existent listener", () => { + const listeners = new EventListeners<() => void>(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener = (): void => {}; + + const removed = listeners.remove(listener); + + assert.strictEqual(removed, false); + }); + + it("should handle multiple listeners", () => { + const listeners = new EventListeners<() => void>(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener1 = (): void => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener2 = (): void => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const listener3 = (): void => {}; + + listeners.add(listener1); + listeners.add(listener2); + listeners.add(listener3); + + assert.strictEqual(listeners.count, 3); + + listeners.remove(listener2); + + assert.strictEqual(listeners.count, 2); + }); + + it("should trigger all listeners synchronously", () => { + const listeners = new EventListeners<(value: string) => void>(); + const calls: string[] = []; + + listeners.add((value) => calls.push(`listener1-${value}`)); + listeners.add((value) => calls.push(`listener2-${value}`)); + + listeners.trigger("test"); + + assert.deepStrictEqual(calls, ["listener1-test", "listener2-test"]); + }); + + it("should trigger listeners with multiple arguments", () => { + const listeners = new EventListeners< + (a: number, b: string, c: boolean) => void + >(); + const calls: [number, string, boolean][] = []; + + listeners.add((a, b, c) => calls.push([a, b, c])); + listeners.trigger(42, "hello", true); + + assert.deepStrictEqual(calls, [[42, "hello", true]]); + }); + + it("should not trigger removed listeners", () => { + const listeners = new EventListeners<() => void>(); + let count1 = 0; + let count2 = 0; + + const listener1 = (): void => { + count1++; + }; + const listener2 = (): void => { + count2++; + }; + + listeners.add(listener1); + const unsubscribe = listeners.add(listener2); + + unsubscribe(); + listeners.trigger(); + + assert.strictEqual(count1, 1); + assert.strictEqual(count2, 0); + }); + + it("should trigger all listeners and await promises", async () => { + const listeners = new EventListeners< + (value: string) => Promise | void + >(); + const results: string[] = []; + + listeners.add(async (value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + results.push(`async1-${value}`); + }); + + listeners.add((value) => { + results.push(`sync-${value}`); + }); + + listeners.add(async (value) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + results.push(`async2-${value}`); + }); + + await listeners.triggerAsync("test"); + + assert.ok(results.includes("async1-test")); + assert.ok(results.includes("sync-test")); + assert.ok(results.includes("async2-test")); + assert.strictEqual(results.length, 3); + }); + + it("should not trigger cleared listeners", () => { + const listeners = new EventListeners<() => void>(); + let called = false; + const listener = (): void => { + called = true; + }; + + listeners.add(listener); + listeners.clear(); + + assert.strictEqual(listeners.count, 0); + listeners.trigger(); + + assert.strictEqual(called, false); + }); +}); diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.ts new file mode 100644 index 00000000..e08ca65e --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.ts @@ -0,0 +1,71 @@ +import { removeFromArray } from "../remove-from-array"; +import { awaitAll } from "../await-all"; + +/** + * A utility class for managing event listeners with type-safe add/remove operations. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class EventListeners any> { + private readonly listeners: TListener[] = []; + + public get count(): number { + return this.listeners.length; + } + + /** + * Adds a new listener to the collection. + * + * @param listener The listener callback to add + * @returns An unsubscribe function that removes this listener when called + */ + public add(listener: TListener): () => void { + this.listeners.push(listener); + return () => this.remove(listener); + } + + /** + * Removes a listener from the collection. + * + * @param listener The listener callback to remove + * @returns true if the listener was found and removed, false otherwise + */ + public remove(listener: TListener): boolean { + return removeFromArray(this.listeners, listener); + } + + /** + * Triggers all listeners synchronously with the provided arguments. + * Any returned promises are ignored. Use triggerAsync() to await them. + * + * @param args The arguments to pass to each listener + */ + public trigger(...args: Parameters): void { + this.listeners.forEach((listener) => { + listener(...args); + }); + } + + /** + * Triggers all listeners and awaits any promises they return. + * Synchronous listeners are called immediately, and any async listeners + * are awaited in parallel. + * + * @param args The arguments to pass to each listener + */ + public async triggerAsync(...args: Parameters): Promise { + await awaitAll( + this.listeners + .map((listener) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return listener(...args); + }) + .filter((result): result is Promise => { + return result instanceof Promise; + }) + ); + } + + public clear(): void { + this.listeners.length = 0; + } +} diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts new file mode 100644 index 00000000..c5ca141c --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.test.ts @@ -0,0 +1,275 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { FixedSizeDocumentCache } from "./fix-sized-cache"; + +describe("fixedSizeDocumentCache", () => { + it("happyPath", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + assert.equal(cache.get(1), doc1); + cache.put(2, doc2); + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + cache.put(3, doc3); + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(3), doc3); + }); + + it("updateExistingEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1_v1 = new Uint8Array([1, 2]); + const doc1_v2 = new Uint8Array([3, 4]); + const doc2 = new Uint8Array([5, 6]); + + cache.put(1, doc1_v1); + assert.equal(cache.get(1), doc1_v1); + cache.put(2, doc2); + assert.equal(cache.get(1), doc1_v1); + assert.equal(cache.get(2), doc2); + cache.put(1, doc1_v2); // Update doc1 + assert.equal(cache.get(1), doc1_v2); + assert.equal(cache.get(2), doc2); + }); + + it("evictOldestEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + cache.put(2, doc2); + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(1), doc1); + cache.put(3, doc3); + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), undefined); + assert.equal(cache.get(3), doc3); + }); + + it("tooLargeEntry", async () => { + const cache = new FixedSizeDocumentCache(2); + const doc1 = new Uint8Array([1, 2, 3]); + + cache.put(1, doc1); + assert.equal(cache.get(1), undefined); + }); + + it("multipleEvictionsInSinglePut", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + const doc4 = new Uint8Array([7, 8, 9, 10, 11, 12, 13, 14]); // 8 bytes + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + // Cache now has 6 bytes total + + cache.put(4, doc4); // Should evict doc1 and doc2 to make room (total: 2+8=10) + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); // Still present + assert.equal(cache.get(4), doc4); + }); + + it("clearCache", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + cache.put(2, doc2); + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + + cache.reset(); + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), undefined); + + // Should be able to add entries after clear + cache.put(3, doc1); + assert.equal(cache.get(3), doc1); + }); + + it("getNonExistentKey", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + cache.put(1, doc1); + assert.equal(cache.get(999), undefined); + }); + + it("updateEntryWithDifferentSizeTriggeringEviction", async () => { + const cache = new FixedSizeDocumentCache(6); + const doc1_v1 = new Uint8Array([1, 2]); + const doc1_v2 = new Uint8Array([1, 2, 3, 4]); // Larger version + const doc2 = new Uint8Array([5, 6]); + const doc3 = new Uint8Array([7, 8]); + + cache.put(1, doc1_v1); + cache.put(2, doc2); + cache.put(3, doc3); + + // Update doc1 with larger version, should evict doc2 + cache.put(1, doc1_v2); + + assert.equal(cache.get(1), doc1_v2); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + }); + + it("singleItemCache", async () => { + const cache = new FixedSizeDocumentCache(2); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + assert.equal(cache.get(1), doc1); + + cache.put(2, doc2); + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), doc2); + }); + + it("multipleGetsOnSameEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + cache.put(2, doc2); + + // Multiple gets on doc1 + cache.get(1); + cache.get(1); + cache.get(1); + + // Order should be: 2 (LRU), 1 (MRU) + cache.put(3, doc3); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + }); + + it("exactlySizedEntry", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2, 3, 4]); // Exactly cache size + + cache.put(1, doc1); + assert.equal(cache.get(1), doc1); + + const doc2 = new Uint8Array([5, 6]); + cache.put(2, doc2); + + // doc1 should be evicted to make room for doc2 + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), doc2); + }); + + it("updateEntryMakesItMostRecent", async () => { + const cache = new FixedSizeDocumentCache(6); + const doc1_v1 = new Uint8Array([1, 2]); + const doc1_v2 = new Uint8Array([3, 4]); + const doc2 = new Uint8Array([5, 6]); + const doc3 = new Uint8Array([7, 8]); + const doc4 = new Uint8Array([9, 10]); + + cache.put(1, doc1_v1); + cache.put(2, doc2); + cache.put(3, doc3); + + // Update doc1 (should move it to most recent) + cache.put(1, doc1_v2); + + // Order should be: 2 (LRU), 3, 1 (MRU) + // Adding doc4 should evict doc2 + cache.put(4, doc4); + + assert.equal(cache.get(1), doc1_v2); + assert.equal(cache.get(2), undefined); // Evicted + assert.equal(cache.get(3), doc3); + assert.equal(cache.get(4), doc4); + }); + + it("alternatingAccessPattern", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + + cache.put(1, doc1); + cache.put(2, doc2); + + // Alternate access between doc1 and doc2 + cache.get(1); + cache.get(2); + cache.get(1); + cache.get(2); + + // Order should be: 1, 2 (MRU) + cache.put(3, doc3); + + assert.equal(cache.get(1), undefined); // Evicted + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(3), doc3); + }); + + it("zeroByteDocs", async () => { + const cache = new FixedSizeDocumentCache(2); + const doc1 = new Uint8Array([]); + const doc2 = new Uint8Array([]); + const doc3 = new Uint8Array([1, 2]); + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + assert.equal(cache.get(3), doc3); + }); + + it("resizeToLargerSizeNoEviction", async () => { + const cache = new FixedSizeDocumentCache(4); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + + cache.put(1, doc1); + cache.put(2, doc2); + + cache.resize(10); + + assert.equal(cache.get(1), doc1); + assert.equal(cache.get(2), doc2); + }); + + it("resizeCausesMultipleEvictions", async () => { + const cache = new FixedSizeDocumentCache(10); + const doc1 = new Uint8Array([1, 2]); + const doc2 = new Uint8Array([3, 4]); + const doc3 = new Uint8Array([5, 6]); + const doc4 = new Uint8Array([7, 8]); + + cache.put(1, doc1); + cache.put(2, doc2); + cache.put(3, doc3); + cache.put(4, doc4); + // Cache has 8 bytes total + + cache.resize(2); + + // Should evict doc1, doc2, doc3 to get down to 2 bytes + assert.equal(cache.get(1), undefined); + assert.equal(cache.get(2), undefined); + assert.equal(cache.get(3), undefined); + assert.equal(cache.get(4), doc4); + }); +}); diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts new file mode 100644 index 00000000..51ad41c1 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts @@ -0,0 +1,119 @@ +// Implements an in-memory fixed-size cache for document contents, + +import type { VaultUpdateId } from "../../persistence/database"; + +// Doubly-linked list node for O(1) LRU operations +class LRUNode { + public constructor( + public key: VaultUpdateId, + public value: Uint8Array, + public prev: LRUNode | null = null, + public next: LRUNode | null = null + ) {} +} + +// evicting the least recently used documents when the size limit is exceeded. +export class FixedSizeDocumentCache { + private currentSizeInBytes: number; + private readonly cache: Map; + private head: LRUNode | null; // Least recently used + private tail: LRUNode | null; // Most recently used + + public constructor(private maxSizeInBytes: number) { + this.currentSizeInBytes = 0; + this.cache = new Map(); + this.head = null; + this.tail = null; + } + + public get(updateId: VaultUpdateId): Uint8Array | undefined { + const node = this.cache.get(updateId); + if (node) { + this.moveToTail(node); + return node.value; + } + + return undefined; + } + + public put(updateId: VaultUpdateId, content: Uint8Array): void { + if (content.byteLength > this.maxSizeInBytes) { + // Document is too large to fit in the cache + return; + } + + // If the document is already in the cache, update it + const existingNode = this.cache.get(updateId); + if (existingNode != null) { + this.currentSizeInBytes -= existingNode.value.byteLength; + this.removeNode(existingNode); + this.cache.delete(updateId); + } + + const newNode = new LRUNode(updateId, content); + this.cache.set(updateId, newNode); + this.addToTail(newNode); + this.currentSizeInBytes += content.byteLength; + this.fitBelowMaxSize(); + } + + public reset(): void { + this.cache.clear(); + this.head = null; + this.tail = null; + this.currentSizeInBytes = 0; + } + + public resize(newMaxSizeInBytes: number): void { + this.maxSizeInBytes = newMaxSizeInBytes; + this.fitBelowMaxSize(); + } + + private fitBelowMaxSize(): void { + // Evict least recently used documents if over size limit + while (this.currentSizeInBytes > this.maxSizeInBytes && this.head) { + const lruNode = this.head; + this.removeNode(lruNode); + this.cache.delete(lruNode.key); + this.currentSizeInBytes -= lruNode.value.byteLength; + } + } + + private removeNode(node: LRUNode): void { + if (node.prev) { + node.prev.next = node.next; + } else { + this.head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.tail = node.prev; + } + + node.prev = null; + node.next = null; + } + + private addToTail(node: LRUNode): void { + node.prev = this.tail; + node.next = null; + + if (this.tail) { + this.tail.next = node; + } + + this.tail = node; + + this.head ??= node; + } + + private moveToTail(node: LRUNode): void { + if (node === this.tail) { + return; + } + this.removeNode(node); + this.addToTail(node); + } +} diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts new file mode 100644 index 00000000..9beb867a --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -0,0 +1,292 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert"; +import { Logger } from "../../tracing/logger"; +import type { RelativePath } from "../../persistence/database"; +import { Locks } from "./locks"; +import { awaitAll } from "../await-all"; +import { sleep } from "../sleep"; +import { SyncResetError } from "../../services/sync-reset-error"; + +describe("withLock", () => { + const testPath: RelativePath = "test/document/path"; + const testPath2: RelativePath = "test/document/path2"; + const logger = new Logger(); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let locks: Locks; + + beforeEach(() => { + locks = new Locks(logger); + }); + + it("should execute function with single key lock", async () => { + let executionCount = 0; + const result = await locks.withLock(testPath, () => { + executionCount++; + return "success"; + }); + + assert.strictEqual(result, "success"); + assert.strictEqual(executionCount, 1); + }); + + it("should execute async function with single key lock", async () => { + let executionCount = 0; + const result = await locks.withLock(testPath, async () => { + executionCount++; + await sleep(10); + return "async-success"; + }); + + assert.strictEqual(result, "async-success"); + assert.strictEqual(executionCount, 1); + }); + + it("should execute function with multiple key locks", async () => { + let executionCount = 0; + const result = await locks.withLock([testPath, testPath2], () => { + executionCount++; + return "multi-success"; + }); + + assert.strictEqual(result, "multi-success"); + assert.strictEqual(executionCount, 1); + }); + + it("should sort multiple keys to prevent deadlocks", async () => { + const executionOrder: string[] = []; + + // Start two concurrent operations with keys in different orders + const promise1 = locks.withLock([testPath2, testPath], async () => { + executionOrder.push("operation1-start"); + await sleep(50); + executionOrder.push("operation1-end"); + return "result1"; + }); + + const promise2 = locks.withLock([testPath, testPath2], async () => { + executionOrder.push("operation2-start"); + await sleep(50); + executionOrder.push("operation2-end"); + return "result2"; + }); + + const [result1, result2] = await awaitAll([promise1, promise2]); + + assert.strictEqual(result1, "result1"); + assert.strictEqual(result2, "result2"); + // One operation should complete entirely before the other starts + assert.deepStrictEqual(executionOrder, [ + "operation1-start", + "operation1-end", + "operation2-start", + "operation2-end" + ]); + }); + + it("should serialize access to same key", async () => { + const executionOrder: string[] = []; + + const promise1 = locks.withLock(testPath, async () => { + executionOrder.push("operation1-start"); + await sleep(50); + executionOrder.push("operation1-end"); + return "result1"; + }); + + const promise2 = locks.withLock(testPath, async () => { + executionOrder.push("operation2-start"); + await sleep(30); + executionOrder.push("operation2-end"); + return "result2"; + }); + + const [result1, result2] = await awaitAll([promise1, promise2]); + + assert.strictEqual(result1, "result1"); + assert.strictEqual(result2, "result2"); + assert.deepStrictEqual(executionOrder, [ + "operation1-start", + "operation1-end", + "operation2-start", + "operation2-end" + ]); + }); + + it("should allow concurrent access to different keys", async () => { + const executionOrder: string[] = []; + + const promise1 = locks.withLock(testPath, async () => { + executionOrder.push("operation1-start"); + await sleep(50); + + executionOrder.push("operation1-end"); + return "result1"; + }); + + const promise2 = locks.withLock(testPath2, async () => { + executionOrder.push("operation2-start"); + await sleep(30); + executionOrder.push("operation2-end"); + return "result2"; + }); + + const [result1, result2] = await awaitAll([promise1, promise2]); + + assert.strictEqual(result1, "result1"); + assert.strictEqual(result2, "result2"); + // Both operations should run concurrently + assert.strictEqual(executionOrder[0], "operation1-start"); + assert.strictEqual(executionOrder[1], "operation2-start"); + }); + + it("should release locks even if function throws", async () => { + const error = new Error("test error"); + + await assert.rejects( + locks.withLock(testPath, () => { + throw error; + }), + { message: "test error" } + ); + + // Lock should be released, allowing another operation + const result = await locks.withLock( + testPath, + () => "success-after-error" + ); + assert.strictEqual(result, "success-after-error"); + }); + + it("should release locks even if async function throws", async () => { + const error = new Error("async test error"); + + await assert.rejects( + locks.withLock(testPath, async () => { + await sleep(10); + + throw error; + }), + { message: "async test error" } + ); + + // Lock should be released, allowing another operation + const result = await locks.withLock( + testPath, + () => "success-after-async-error" + ); + assert.strictEqual(result, "success-after-async-error"); + }); + + it("should handle empty array of keys", async () => { + const result = await locks.withLock([], () => "empty-keys"); + assert.strictEqual(result, "empty-keys"); + }); + + it("should maintain FIFO order for multiple waiters", async () => { + const executionOrder: string[] = []; + + // Start first operation that holds the lock + const firstPromise = locks.withLock(testPath, async () => { + executionOrder.push("first-start"); + await sleep(100); + executionOrder.push("first-end"); + return "first"; + }); + + // Small delay to ensure first operation starts + await sleep(10); + + // Queue second and third operations + const secondPromise = locks.withLock(testPath, async () => { + executionOrder.push("second-start"); + await sleep(50); + executionOrder.push("second-end"); + return "second"; + }); + + const thirdPromise = locks.withLock(testPath, async () => { + executionOrder.push("third-start"); + await sleep(20); + executionOrder.push("third-end"); + return "third"; + }); + + const [first, second, third] = await awaitAll([ + firstPromise, + secondPromise, + thirdPromise + ]); + + assert.strictEqual(first, "first"); + assert.strictEqual(second, "second"); + assert.strictEqual(third, "third"); + assert.deepStrictEqual(executionOrder, [ + "first-start", + "first-end", + "second-start", + "second-end", + "third-start", + "third-end" + ]); + }); +}); + +describe("reset", () => { + const testPath: RelativePath = "test/document/path"; + const logger = new Logger(); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let locks: Locks; + + beforeEach(() => { + locks = new Locks(logger); + }); + + it("should reject pending waiters with SyncResetError while running operation completes", async () => { + const firstPromise = locks.withLock(testPath, async () => { + await sleep(2); + return "first"; + }); + + await sleep(1); + + const secondPromise = locks.withLock(testPath, async () => "second"); + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function + + locks.reset(); + + assert.strictEqual(await firstPromise, "first"); + + await assert.rejects(secondPromise, (err: Error) => { + assert.ok(err instanceof SyncResetError); + return true; + }); + }); + + it("should allow locks to work normally after reset", async () => { + const firstPromise = locks.withLock(testPath, async () => { + await sleep(1); + return "first"; + }); + + await sleep(1); + + const secondPromise = locks.withLock(testPath, async () => "second"); + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function + + locks.reset(); + + await firstPromise; + + const result = await locks.withLock(testPath, () => "after-reset"); + assert.strictEqual(result, "after-reset"); + }); + + it("should handle reset with no pending operations", async () => { + locks.reset(); + + const result = await locks.withLock(testPath, () => "success"); + assert.strictEqual(result, "success"); + }); +}); diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts new file mode 100644 index 00000000..e55c76b0 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -0,0 +1,166 @@ +import { SyncResetError } from "../../services/sync-reset-error"; +import type { Logger } from "../../tracing/logger"; +import { awaitAll } from "../await-all"; + +/** + * Manages exclusive locks on items to prevent concurrent modifications. + * Locks are granted in FIFO order. + * + * @template T The type of the key used for locking + */ +export class Locks { + /** Currently locked keys */ + private readonly locked = new Set(); + + /** Queue of resolve functions waiting for each key */ + private readonly waiters = new Map< + T, + [() => unknown, (err: unknown) => unknown][] + >(); + + public constructor(private readonly logger?: Logger) {} + + /** + * Executes a function while holding exclusive locks on one or more keys. + * + * This method ensures that the provided function runs with exclusive access to the + * specified key(s). Multiple keys are sorted to prevent deadlocks when different + * operations request the same keys in different orders. + * + * @template R The return type of the function to execute + * @param keyOrKeys A single key or array of keys to lock during function execution + * @param fn The function to execute while holding the lock(s). Can be sync or async. + * @returns A Promise that resolves to the return value of the executed function + * + * @example + * ```typescript + * // Lock a single key + * const result = await locks.withLock('file1', () => { + * // Critical section - only one operation can access 'file1' at a time + * return processFile('file1'); + * }); + * + * // Lock multiple keys (prevents deadlocks through consistent ordering) + * await locks.withLock(['file1', 'file2'], async () => { + * // Critical section - exclusive access to both files + * await moveFile('file1', 'file2'); + * }); + * ``` + * + * @throws Any error thrown by the provided function will be propagated after locks are released + */ + public async withLock( + keyOrKeys: T | T[], + fn: () => R | Promise + ): Promise { + const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]; + + // Deduplicate keys to prevent deadlock from acquiring same lock twice + const uniqueKeys = Array.from(new Set(keys)); + uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks + + await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key))); + + try { + return await fn(); + } finally { + uniqueKeys.forEach((key) => { + this.unlock(key); + }); + } + } + + public reset(): void { + // Resolve all waiting promises before clearing to prevent deadlock + // Any operation waiting for a lock will be granted access immediately + for (const waiting of this.waiters.values()) { + for (const [_, reject] of waiting) { + reject(new SyncResetError()); + } + } + this.locked.clear(); + this.waiters.clear(); + } + + /** + * Attempts to acquire a lock immediately without waiting. + * Must call `unlock()` if successful. + * + * @param key The key to lock + * @returns `true` if lock acquired, `false` if already locked + */ + public tryLock(key: T): boolean { + if (this.locked.has(key)) { + return false; + } + + this.locked.add(key); + + return true; + } + + /** + * Waits to acquire a lock, blocking until available. + * Operations are queued in FIFO order. Must call `unlock()` when done. + * + * @param key The key to wait for and lock + * @returns Promise that resolves when lock is acquired + */ + public async waitForLock(key: T): Promise { + if (this.tryLock(key)) { + return Promise.resolve(); + } + + this.logger?.debug(`Waiting for lock on ${key}`); + + return new Promise((resolve, reject) => { + // DefaultDict behavior + let waiting = this.waiters.get(key); + if (!waiting) { + waiting = []; + this.waiters.set(key, waiting); + } + + waiting.push([resolve, reject]); + }); + } + + /** + * Releases a lock and grants access to the next waiting operation in FIFO order. + * Removes the key from locked set if no waiters. + * + * @param key The key to unlock + * @throws {Error} If key is not currently locked + */ + public unlock(key: T): void { + if (!this.locked.has(key)) { + return; + } + + // Remove first waiter to ensure FIFO order + const [resolveNextWaiting, _] = this.waiters.get(key)?.shift() ?? []; + + if (resolveNextWaiting) { + this.logger?.debug(`Granted lock on ${key}`); + resolveNextWaiting(); + } else { + this.locked.delete(key); + } + } +} + +export class Lock { + private readonly locks: Locks; + + public constructor(logger?: Logger) { + this.locks = new Locks(logger); + } + + public async withLock(fn: () => R | Promise): Promise { + return this.locks.withLock(true, fn); + } + + public reset(): void { + this.locks.reset(); + } +} diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts new file mode 100644 index 00000000..7b7271d7 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { CoveredValues } from "./min-covered"; + +describe("CoveredValues", () => { + it("should initialize with the given min value", () => { + const covered = new CoveredValues(5); + assert.strictEqual(covered.min, 5); + }); + + it("should add values greater than min", () => { + const covered = new CoveredValues(0); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + assert.strictEqual(covered.min, 1); + covered.add(4); + assert.strictEqual(covered.min, 1); + covered.add(2); + assert.strictEqual(covered.min, 4); + }); + + it("should ignore duplicate values", () => { + const covered = new CoveredValues(0); + covered.add(3); + covered.add(3); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + covered.add(2); + assert.strictEqual(covered.min, 3); + }); + + it("should handle multiple consecutive values", () => { + const covered = new CoveredValues(132); + for (let i = 250; i > 132; i--) { + assert.strictEqual(covered.min, 132); + covered.add(i); + } + assert.strictEqual(covered.min, 250); + }); + + it("should handle adding values lower than current min", () => { + const covered = new CoveredValues(5); + covered.add(3); + assert.strictEqual(covered.min, 5); + covered.add(6); + assert.strictEqual(covered.min, 6); + }); + + it("should auto-advance when setting min value", () => { + const covered = new CoveredValues(5); + covered.add(7); + covered.add(8); + covered.add(9); + assert.strictEqual(covered.min, 5); + // Setting min to 6 should auto-advance through 7, 8, 9 + covered.min = 6; + assert.strictEqual(covered.min, 9); + covered.add(10); + assert.strictEqual(covered.min, 10); + }); + + it("should handle setting min value with no consecutive values", () => { + const covered = new CoveredValues(5); + covered.add(10); + covered.add(15); + assert.strictEqual(covered.min, 5); + // Setting min to 8 should not auto-advance (no consecutive values) + covered.min = 8; + assert.strictEqual(covered.min, 8); + // Add 9 to trigger auto-advance to 10 + covered.add(9); + assert.strictEqual(covered.min, 10); + }); +}); diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts new file mode 100644 index 00000000..8b38822f --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -0,0 +1,61 @@ +/** + * A class that tracks the minimum covered value in a sequence of numbers. + * It keeps track of a minimum value based on the seen values. + * + * It expects integers slightly out of order and makes sure that the value of `min` is + * always the minimum of the seen values. This is done with bounded memory usage. + * + * @example + * ```typescript + * const covered = new CoveredValues(0); + * covered.add(2); // seenValues = [2], min = 0 + * covered.add(1); // seenValues = [], min = 2 + * covered.min; // returns 2 + * ``` + */ +export class CoveredValues { + private seenValues: number[] = []; + + public constructor(private minValue: number) {} + + public get min(): number { + return this.minValue; + } + + public set min(value: number) { + this.minValue = Math.max(value, this.minValue); + this.seenValues = this.seenValues.filter((v) => v > this.minValue); + this.advanceMinWhilePossible(); + } + + public add(value: number | undefined): void { + if (value === undefined || value < this.minValue) { + return; + } + + let i = 0; + while (i < this.seenValues.length && this.seenValues[i] < value) { + i++; + } + + if (i === this.seenValues.length) { + this.seenValues.push(value); + } else if (this.seenValues[i] === value) { + return; + } else { + this.seenValues.splice(i, 0, value); + } + + this.advanceMinWhilePossible(); + } + + private advanceMinWhilePossible(): void { + while ( + this.seenValues.length > 0 && + this.seenValues[0] === this.minValue + 1 + ) { + this.seenValues.shift(); + this.minValue++; + } + } +} diff --git a/frontend/sync-client/src/utils/debugging/log-to-console.ts b/frontend/sync-client/src/utils/debugging/log-to-console.ts new file mode 100644 index 00000000..c47f18f6 --- /dev/null +++ b/frontend/sync-client/src/utils/debugging/log-to-console.ts @@ -0,0 +1,24 @@ +import type { SyncClient } from "../../sync-client"; +import type { LogLine } from "../../tracing/logger"; +import { LogLevel } from "../../tracing/logger"; + +export function logToConsole(client: SyncClient): void { + client.logger.onLogEmitted.add((logLine: LogLine) => { + const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; + + switch (logLine.level) { + case LogLevel.ERROR: + console.error(formatted); + break; + case LogLevel.WARNING: + console.warn(formatted); + break; + case LogLevel.INFO: + console.info(formatted); + break; + case LogLevel.DEBUG: + console.debug(formatted); + break; + } + }); +} diff --git a/frontend/sync-client/src/utils/debugging/slow-fetch-factory.ts b/frontend/sync-client/src/utils/debugging/slow-fetch-factory.ts new file mode 100644 index 00000000..e2908af0 --- /dev/null +++ b/frontend/sync-client/src/utils/debugging/slow-fetch-factory.ts @@ -0,0 +1,20 @@ +import { sleep } from "../sleep"; + +export const slowFetchFactory = + (jitterScaleInSeconds: number) => + async ( + input: string | URL | globalThis.Request, + init?: RequestInit + ): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000); + } + + const response = await fetch(input, init); + + if (jitterScaleInSeconds > 0) { + await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000); + } + + return response; + }; diff --git a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts new file mode 100644 index 00000000..c64bff18 --- /dev/null +++ b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts @@ -0,0 +1,81 @@ +import { sleep } from "../sleep"; +import { Locks } from "../data-structures/locks"; +import type { Logger } from "../../tracing/logger"; + +export function slowWebSocketFactory( + jitterScaleInSeconds: number, + logger: Logger +): typeof WebSocket { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return class FlakyWebSocket extends WebSocket { + private static readonly RECEIVE_KEY = "websocket-receive"; + private static readonly SEND_KEY = "websocket-send"; + + private readonly locks = new Locks(logger); + + public set onopen(callback: ((event: Event) => void) | null) { + super.onopen = async (event: Event): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + callback?.(event); + }; + } + + public set onmessage(callback: ((event: MessageEvent) => void) | null) { + super.onmessage = async (event: MessageEvent): Promise => { + await this.locks.withLock( + FlakyWebSocket.RECEIVE_KEY, + async () => { + if (jitterScaleInSeconds > 0) { + await sleep( + Math.random() * jitterScaleInSeconds * 1000 + ); + } + + callback?.(event); + } + ); + }; + } + + public set onclose(callback: ((event: CloseEvent) => void) | null) { + super.onclose = async (event: CloseEvent): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + callback?.(event); + }; + } + + public set onerror(callback: ((event: Event) => void) | null) { + super.onerror = async (event: Event): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + callback?.(event); + }; + } + + public send( + data: string | ArrayBufferLike | Blob | ArrayBufferView + ): void { + this.waitingSend(data).catch((error: unknown) => { + logger.error(`Error sending WebSocket message: ${error}`); + }); + } + + private async waitingSend( + data: string | ArrayBufferLike | Blob | ArrayBufferView + ): Promise { + // maintain message order + await this.locks.withLock(FlakyWebSocket.SEND_KEY, async () => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + super.send(data); + }); + } + } as unknown as typeof WebSocket; +} diff --git a/frontend/sync-client/src/utils/deserialize.ts b/frontend/sync-client/src/utils/deserialize.ts deleted file mode 100644 index 4255479f..00000000 --- a/frontend/sync-client/src/utils/deserialize.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { base64ToBytes } from "byte-base64"; - -export function deserialize(data: string): Uint8Array { - return base64ToBytes(data); -} diff --git a/frontend/sync-client/src/utils/find-matching-file.ts b/frontend/sync-client/src/utils/find-matching-file.ts index 10545f2c..c3d323d3 100644 --- a/frontend/sync-client/src/utils/find-matching-file.ts +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -3,12 +3,12 @@ import { EMPTY_HASH } from "./hash"; // TODO: make this smarter so that offline files can be renamed & edited at the same time export function findMatchingFile( - contentHash: string, - candidates: DocumentRecord[] + contentHash: string, + candidates: DocumentRecord[] ): DocumentRecord | undefined { - if (contentHash === EMPTY_HASH) { - return undefined; - } + if (contentHash === EMPTY_HASH) { + return undefined; + } - return candidates.find(({ metadata }) => metadata?.hash === contentHash); + return candidates.find(({ metadata }) => metadata?.hash === contentHash); } diff --git a/frontend/sync-client/src/utils/get-random-color.ts b/frontend/sync-client/src/utils/get-random-color.ts index 543b943e..38015734 100644 --- a/frontend/sync-client/src/utils/get-random-color.ts +++ b/frontend/sync-client/src/utils/get-random-color.ts @@ -1,9 +1,9 @@ export function getRandomColor(name: string): string { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = (hash << 5) - hash + name.charCodeAt(i); - hash |= 0; // Convert to 32bit integer - } - const normalised = hash / 0x7fffffff; - return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) - hash + name.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + const normalised = hash / 0x7fffffff; + return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`; } diff --git a/frontend/sync-client/src/utils/globs-to-regexes.test.ts b/frontend/sync-client/src/utils/globs-to-regexes.test.ts index 3e986ca4..f2b11787 100644 --- a/frontend/sync-client/src/utils/globs-to-regexes.test.ts +++ b/frontend/sync-client/src/utils/globs-to-regexes.test.ts @@ -4,10 +4,10 @@ import { Logger } from "../tracing/logger"; import { globsToRegexes } from "./globs-to-regexes"; describe("globsToRegexes", () => { - it("basicExample", async () => { - const [regex] = globsToRegexes([".git/**"], new Logger()); + it("basicExample", async () => { + const [regex] = globsToRegexes([".git/**"], new Logger()); - assert.ok(regex.test(".git/objects/object")); - assert.ok(regex.test(".git/objects/.object")); - }); + assert.ok(regex.test(".git/objects/object")); + assert.ok(regex.test(".git/objects/.object")); + }); }); diff --git a/frontend/sync-client/src/utils/globs-to-regexes.ts b/frontend/sync-client/src/utils/globs-to-regexes.ts index 1e8ad775..1cd048d3 100644 --- a/frontend/sync-client/src/utils/globs-to-regexes.ts +++ b/frontend/sync-client/src/utils/globs-to-regexes.ts @@ -2,17 +2,20 @@ import { makeRe } from "minimatch"; import type { Logger } from "../tracing/logger"; export function globsToRegexes(globs: string[], logger: Logger): RegExp[] { - return globs - .map((pattern) => { - const result = makeRe(pattern, { - dot: true - }); - if (result === false) { - logger.warn( - `Failed to parse ${pattern}' as a glob pattern, skipping it` - ); - } - return result; - }) - .filter((pattern) => pattern !== false); + return ( + globs + .map((pattern) => { + const result = makeRe(pattern, { + dot: true + }); + if (result === false) { + logger.warn( + `Failed to parse ${pattern}' as a glob pattern, skipping it` + ); + } + return result; + }) + // eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item + .filter((pattern) => pattern !== false) + ); } diff --git a/frontend/sync-client/src/utils/hash.ts b/frontend/sync-client/src/utils/hash.ts index cd965db5..906b6fad 100644 --- a/frontend/sync-client/src/utils/hash.ts +++ b/frontend/sync-client/src/utils/hash.ts @@ -1,12 +1,12 @@ // https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript export function hash(content: Uint8Array): string { - let result = 0; - // eslint-disable-next-line @typescript-eslint/prefer-for-of - for (let i = 0; i < content.length; i++) { - result = (result << 5) - result + content[i]; - result |= 0; // Convert to 32bit integer - } - return Math.abs(result).toString(16).padStart(8, "0"); + let result = 0; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < content.length; i++) { + result = (result << 5) - result + content[i]; + result |= 0; // Convert to 32bit integer + } + return Math.abs(result).toString(16).padStart(8, "0"); } export const EMPTY_HASH = hash(new Uint8Array(0)); diff --git a/frontend/sync-client/src/utils/is-binary.ts b/frontend/sync-client/src/utils/is-binary.ts new file mode 100644 index 00000000..aac92711 --- /dev/null +++ b/frontend/sync-client/src/utils/is-binary.ts @@ -0,0 +1,16 @@ +// Text is unlikely to contain null bytes, so we can use that to distinguish binary files. +export function isBinary(content: Uint8Array): boolean { + for (const byte of content) { + if (byte === 0) { + return true; + } + } + + try { + new TextDecoder("utf-8", { fatal: true }).decode(content); + } catch { + return true; + } + + return false; +} diff --git a/frontend/sync-client/src/utils/is-equal-bytes.test.ts b/frontend/sync-client/src/utils/is-equal-bytes.test.ts deleted file mode 100644 index a887309f..00000000 --- a/frontend/sync-client/src/utils/is-equal-bytes.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { isEqualBytes } from "./is-equal-bytes"; - -describe("isEqualBytes", () => { - it("should return true for equal byte arrays", () => { - const bytes1 = new Uint8Array([1, 2, 3, 4]); - const bytes2 = new Uint8Array([1, 2, 3, 4]); - assert.strictEqual(isEqualBytes(bytes1, bytes2), true); - }); - - it("should return false for byte arrays of different lengths", () => { - const bytes1 = new Uint8Array([1, 2, 3, 4]); - const bytes2 = new Uint8Array([1, 2, 3]); - assert.strictEqual(isEqualBytes(bytes1, bytes2), false); - }); - - it("should return true for empty byte arrays", () => { - const bytes1 = new Uint8Array([]); - const bytes2 = new Uint8Array([]); - assert.strictEqual(isEqualBytes(bytes1, bytes2), true); - }); - - it("should return false for byte arrays with same length but different content", () => { - const bytes1 = new Uint8Array([1, 2, 3, 4]); - const bytes2 = new Uint8Array([4, 3, 2, 1]); - assert.strictEqual(isEqualBytes(bytes1, bytes2), false); - }); -}); diff --git a/frontend/sync-client/src/utils/is-equal-bytes.ts b/frontend/sync-client/src/utils/is-equal-bytes.ts deleted file mode 100644 index d0688d44..00000000 --- a/frontend/sync-client/src/utils/is-equal-bytes.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function isEqualBytes(bytes1: Uint8Array, bytes2: Uint8Array): boolean { - if (bytes1.length !== bytes2.length) { - return false; - } - - for (let i = 0; i < bytes1.length; i++) { - if (bytes1[i] !== bytes2[i]) { - return false; - } - } - - return true; -} diff --git a/frontend/sync-client/src/utils/is-file-type-mergable.test.ts b/frontend/sync-client/src/utils/is-file-type-mergable.test.ts index 3f3fffbb..fd316588 100644 --- a/frontend/sync-client/src/utils/is-file-type-mergable.test.ts +++ b/frontend/sync-client/src/utils/is-file-type-mergable.test.ts @@ -2,41 +2,72 @@ import { describe, it } from "node:test"; import assert from "node:assert"; import { isFileTypeMergable } from "./is-file-type-mergable"; +const mergableExtensions = ["md", "txt"]; describe("isFileTypeMergable", () => { - it("should return true for .md files", () => { - assert.strictEqual(isFileTypeMergable(".md"), true); - assert.strictEqual(isFileTypeMergable("hi.md"), true); - assert.strictEqual( - isFileTypeMergable("my/path/to/my/document.md"), - true - ); - }); + it("should return true for .md files", () => { + assert.strictEqual(isFileTypeMergable(".md", mergableExtensions), true); + assert.strictEqual( + isFileTypeMergable("hi.md", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable("my/path/to/my/document.md", mergableExtensions), + true + ); + }); - it("should return true for .txt files", () => { - assert.strictEqual(isFileTypeMergable(".txt"), true); - assert.strictEqual(isFileTypeMergable("hi.txt"), true); - assert.strictEqual( - isFileTypeMergable("my/path/to/my/document.txt"), - true - ); - }); + it("should return true for .txt files", () => { + assert.strictEqual( + isFileTypeMergable(".txt", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable("hi.txt", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable( + "my/path/to/my/document.txt", + mergableExtensions + ), + true + ); + }); - it("should be case insensitive", () => { - assert.strictEqual(isFileTypeMergable("hi.MD"), true); - assert.strictEqual( - isFileTypeMergable("my/path/to/my/DOCUMENT.MD"), - true - ); - assert.strictEqual(isFileTypeMergable("hi.TXT"), true); - assert.strictEqual( - isFileTypeMergable("my/path/to/my/DOCUMENT.TXT"), - true - ); - }); + it("should be case insensitive", () => { + assert.strictEqual( + isFileTypeMergable("hi.MD", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable("my/path/to/my/DOCUMENT.MD", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable("hi.TXT", mergableExtensions), + true + ); + assert.strictEqual( + isFileTypeMergable( + "my/path/to/my/DOCUMENT.TXT", + mergableExtensions + ), + true + ); + }); - it("should return false for non-mergable file types", () => { - assert.strictEqual(isFileTypeMergable(".json"), false); - assert.strictEqual(isFileTypeMergable("HELLO.JSON"), false); - assert.strictEqual(isFileTypeMergable("my/config.yml"), false); - }); + it("should return false for non-mergable file types", () => { + assert.strictEqual( + isFileTypeMergable(".json", mergableExtensions), + false + ); + assert.strictEqual( + isFileTypeMergable("HELLO.JSON", mergableExtensions), + false + ); + assert.strictEqual( + isFileTypeMergable("my/config.yml", mergableExtensions), + false + ); + }); }); diff --git a/frontend/sync-client/src/utils/is-file-type-mergable.ts b/frontend/sync-client/src/utils/is-file-type-mergable.ts index 3b149285..a895b3e2 100644 --- a/frontend/sync-client/src/utils/is-file-type-mergable.ts +++ b/frontend/sync-client/src/utils/is-file-type-mergable.ts @@ -1,6 +1,9 @@ -export function isFileTypeMergable(pathOrFileName: string): boolean { - const parts = pathOrFileName.split("."); - const fileExtension = parts.at(-1) ?? ""; +export function isFileTypeMergable( + pathOrFileName: string, + mergeableExtensions: string[] +): boolean { + const parts = pathOrFileName.split("."); + const fileExtension = parts.at(-1) ?? ""; - return ["md", "txt"].includes(fileExtension.toLowerCase()); + return mergeableExtensions.includes(fileExtension.toLowerCase()); } diff --git a/frontend/sync-client/src/utils/line-and-column-to-position.test.ts b/frontend/sync-client/src/utils/line-and-column-to-position.test.ts index 82d752c9..e597cc39 100644 --- a/frontend/sync-client/src/utils/line-and-column-to-position.test.ts +++ b/frontend/sync-client/src/utils/line-and-column-to-position.test.ts @@ -3,42 +3,42 @@ import assert from "node:assert"; import { lineAndColumnToPosition } from "./line-and-column-to-position"; describe("lineAndColumnToPosition", () => { - it("should return the correct position for the first line", () => { - const text = "Hello\nWorld"; - const position = lineAndColumnToPosition(text, 0, 3); - assert.strictEqual(position, 3); - }); + it("should return the correct position for the first line", () => { + const text = "Hello\nWorld"; + const position = lineAndColumnToPosition(text, 0, 3); + assert.strictEqual(position, 3); + }); - it("should return the correct position for the second line", () => { - const text = "Hello\nWorld"; - const position = lineAndColumnToPosition(text, 1, 2); - assert.strictEqual(position, 8); - }); + it("should return the correct position for the second line", () => { + const text = "Hello\nWorld"; + const position = lineAndColumnToPosition(text, 1, 2); + assert.strictEqual(position, 8); + }); - it("should return the correct position for an empty string", () => { - const text = ""; - const position = lineAndColumnToPosition(text, 0, 0); - assert.strictEqual(position, 0); - }); + it("should return the correct position for an empty string", () => { + const text = ""; + const position = lineAndColumnToPosition(text, 0, 0); + assert.strictEqual(position, 0); + }); - it("with carrige return", () => { - assert.strictEqual(lineAndColumnToPosition("a\nb", 1, 1), 3); - assert.strictEqual(lineAndColumnToPosition("a\r\nb", 1, 1), 3); - }); + it("with carrige return", () => { + assert.strictEqual(lineAndColumnToPosition("a\nb", 1, 1), 3); + assert.strictEqual(lineAndColumnToPosition("a\r\nb", 1, 1), 3); + }); - it("should handle multi-line strings with varying lengths", () => { - const text = "Line1\nLongerLine2\nShort3"; - const position = lineAndColumnToPosition(text, 2, 4); - assert.strictEqual(position, 22); - }); + it("should handle multi-line strings with varying lengths", () => { + const text = "Line1\nLongerLine2\nShort3"; + const position = lineAndColumnToPosition(text, 2, 4); + assert.strictEqual(position, 22); + }); - it("should throw an error if the line number is out of range", () => { - const text = "Line1\nLine2"; - assert.throws(() => lineAndColumnToPosition(text, 3, 0)); - }); + it("should throw an error if the line number is out of range", () => { + const text = "Line1\nLine2"; + assert.throws(() => lineAndColumnToPosition(text, 3, 0)); + }); - it("should throw an error if the column number is out of range", () => { - const text = "Line1\nLine2"; - assert.throws(() => lineAndColumnToPosition(text, 1, 10)); - }); + it("should throw an error if the column number is out of range", () => { + const text = "Line1\nLine2"; + assert.throws(() => lineAndColumnToPosition(text, 1, 10)); + }); }); diff --git a/frontend/sync-client/src/utils/line-and-column-to-position.ts b/frontend/sync-client/src/utils/line-and-column-to-position.ts index 670d8cac..05ac7be8 100644 --- a/frontend/sync-client/src/utils/line-and-column-to-position.ts +++ b/frontend/sync-client/src/utils/line-and-column-to-position.ts @@ -9,26 +9,26 @@ * @throws Error if column number is out of range */ export function lineAndColumnToPosition( - text: string, - line: number, - column: number + text: string, + line: number, + column: number ): number { - const lines = text.replace("\r", "").split("\n"); + const lines = text.replaceAll("\r", "").split("\n"); - if (line >= lines.length) { - throw new Error(`Line number ${line} is out of range.`); - } + if (line >= lines.length) { + throw new Error(`Line number ${line} is out of range.`); + } - if (column > lines[line].length) { - throw new Error(`Column number ${column} is out of range.`); - } + if (column > lines[line].length) { + throw new Error(`Column number ${column} is out of range.`); + } - let position = 0; - for (let i = 0; i < line; i++) { - position += lines[i].length + 1; - } + let position = 0; + for (let i = 0; i < line; i++) { + position += lines[i].length + 1; + } - position += column; + position += column; - return position; + return position; } diff --git a/frontend/sync-client/src/utils/locks.test.ts b/frontend/sync-client/src/utils/locks.test.ts deleted file mode 100644 index 5626becc..00000000 --- a/frontend/sync-client/src/utils/locks.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { describe, it, beforeEach } from "node:test"; -import assert from "node:assert"; -import { Logger } from "../tracing/logger"; -import type { RelativePath } from "../persistence/database"; -import { Locks } from "./locks"; - -describe("withLock", () => { - const testPath: RelativePath = "test/document/path"; - const testPath2: RelativePath = "test/document/path2"; - const logger = new Logger(); - - // eslint-disable-next-line @typescript-eslint/init-declarations - let locks: Locks; - - beforeEach(() => { - locks = new Locks(logger); - }); - - it("should execute function with single key lock", async () => { - let executionCount = 0; - const result = await locks.withLock(testPath, () => { - executionCount++; - return "success"; - }); - - assert.strictEqual(result, "success"); - assert.strictEqual(executionCount, 1); - }); - - it("should execute async function with single key lock", async () => { - let executionCount = 0; - const result = await locks.withLock(testPath, async () => { - executionCount++; - await new Promise((resolve) => setTimeout(resolve, 10)); - return "async-success"; - }); - - assert.strictEqual(result, "async-success"); - assert.strictEqual(executionCount, 1); - }); - - it("should execute function with multiple key locks", async () => { - let executionCount = 0; - const result = await locks.withLock([testPath, testPath2], () => { - executionCount++; - return "multi-success"; - }); - - assert.strictEqual(result, "multi-success"); - assert.strictEqual(executionCount, 1); - }); - - it("should sort multiple keys to prevent deadlocks", async () => { - const executionOrder: string[] = []; - - // Start two concurrent operations with keys in different orders - const promise1 = locks.withLock([testPath2, testPath], async () => { - executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); - executionOrder.push("operation1-end"); - return "result1"; - }); - - const promise2 = locks.withLock([testPath, testPath2], async () => { - executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); - executionOrder.push("operation2-end"); - return "result2"; - }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - assert.strictEqual(result1, "result1"); - assert.strictEqual(result2, "result2"); - // One operation should complete entirely before the other starts - assert.deepStrictEqual(executionOrder, [ - "operation1-start", - "operation1-end", - "operation2-start", - "operation2-end" - ]); - }); - - it("should serialize access to same key", async () => { - const executionOrder: string[] = []; - - const promise1 = locks.withLock(testPath, async () => { - executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); - executionOrder.push("operation1-end"); - return "result1"; - }); - - const promise2 = locks.withLock(testPath, async () => { - executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); - executionOrder.push("operation2-end"); - return "result2"; - }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - assert.strictEqual(result1, "result1"); - assert.strictEqual(result2, "result2"); - assert.deepStrictEqual(executionOrder, [ - "operation1-start", - "operation1-end", - "operation2-start", - "operation2-end" - ]); - }); - - it("should allow concurrent access to different keys", async () => { - const executionOrder: string[] = []; - - const promise1 = locks.withLock(testPath, async () => { - executionOrder.push("operation1-start"); - await new Promise((resolve) => setTimeout(resolve, 50)); - executionOrder.push("operation1-end"); - return "result1"; - }); - - const promise2 = locks.withLock(testPath2, async () => { - executionOrder.push("operation2-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); - executionOrder.push("operation2-end"); - return "result2"; - }); - - const [result1, result2] = await Promise.all([promise1, promise2]); - - assert.strictEqual(result1, "result1"); - assert.strictEqual(result2, "result2"); - // Both operations should run concurrently - assert.strictEqual(executionOrder[0], "operation1-start"); - assert.strictEqual(executionOrder[1], "operation2-start"); - }); - - it("should release locks even if function throws", async () => { - const error = new Error("test error"); - - await assert.rejects( - locks.withLock(testPath, () => { - throw error; - }), - { message: "test error" } - ); - - // Lock should be released, allowing another operation - const result = await locks.withLock( - testPath, - () => "success-after-error" - ); - assert.strictEqual(result, "success-after-error"); - }); - - it("should release locks even if async function throws", async () => { - const error = new Error("async test error"); - - await assert.rejects( - locks.withLock(testPath, async () => { - await new Promise((resolve) => setTimeout(resolve, 10)); - throw error; - }), - { message: "async test error" } - ); - - // Lock should be released, allowing another operation - const result = await locks.withLock( - testPath, - () => "success-after-async-error" - ); - assert.strictEqual(result, "success-after-async-error"); - }); - - it("should handle empty array of keys", async () => { - const result = await locks.withLock([], () => "empty-keys"); - assert.strictEqual(result, "empty-keys"); - }); - - it("should maintain FIFO order for multiple waiters", async () => { - const executionOrder: string[] = []; - - // Start first operation that holds the lock - const firstPromise = locks.withLock(testPath, async () => { - executionOrder.push("first-start"); - await new Promise((resolve) => setTimeout(resolve, 100)); - executionOrder.push("first-end"); - return "first"; - }); - - // Small delay to ensure first operation starts - await new Promise((resolve) => setTimeout(resolve, 10)); - - // Queue second and third operations - const secondPromise = locks.withLock(testPath, async () => { - executionOrder.push("second-start"); - await new Promise((resolve) => setTimeout(resolve, 30)); - executionOrder.push("second-end"); - return "second"; - }); - - const thirdPromise = locks.withLock(testPath, async () => { - executionOrder.push("third-start"); - await new Promise((resolve) => setTimeout(resolve, 20)); - executionOrder.push("third-end"); - return "third"; - }); - - const [first, second, third] = await Promise.all([ - firstPromise, - secondPromise, - thirdPromise - ]); - - assert.strictEqual(first, "first"); - assert.strictEqual(second, "second"); - assert.strictEqual(third, "third"); - assert.deepStrictEqual(executionOrder, [ - "first-start", - "first-end", - "second-start", - "second-end", - "third-start", - "third-end" - ]); - }); -}); diff --git a/frontend/sync-client/src/utils/locks.ts b/frontend/sync-client/src/utils/locks.ts deleted file mode 100644 index e09da236..00000000 --- a/frontend/sync-client/src/utils/locks.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Logger } from "../tracing/logger"; - -/** - * Manages exclusive locks on items to prevent concurrent modifications. - * Locks are granted in FIFO order. - * - * @template T The type of the key used for locking - */ -export class Locks { - /** Currently locked keys */ - private readonly locked = new Set(); - - /** Queue of resolve functions waiting for each key */ - private readonly waiters = new Map unknown)[]>(); - - public constructor(private readonly logger?: Logger) {} - - /** - * Executes a function while holding exclusive locks on one or more keys. - * - * This method ensures that the provided function runs with exclusive access to the - * specified key(s). Multiple keys are sorted to prevent deadlocks when different - * operations request the same keys in different orders. - * - * @template R The return type of the function to execute - * @param keyOrKeys A single key or array of keys to lock during function execution - * @param fn The function to execute while holding the lock(s). Can be sync or async. - * @returns A Promise that resolves to the return value of the executed function - * - * @example - * ```typescript - * // Lock a single key - * const result = await locks.withLock('file1', () => { - * // Critical section - only one operation can access 'file1' at a time - * return processFile('file1'); - * }); - * - * // Lock multiple keys (prevents deadlocks through consistent ordering) - * await locks.withLock(['file1', 'file2'], async () => { - * // Critical section - exclusive access to both files - * await moveFile('file1', 'file2'); - * }); - * ``` - * - * @throws Any error thrown by the provided function will be propagated after locks are released - */ - public async withLock( - keyOrKeys: T | T[], - fn: () => R | Promise - ): Promise { - const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]; - keys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks - - await Promise.all(keys.map(async (key) => this.waitForLock(key))); - - try { - return await fn(); - } finally { - keys.forEach((key) => { - this.unlock(key); - }); - } - } - - /** - * Attempts to acquire a lock immediately without waiting. - * Must call `unlock()` if successful. - * - * @param key The key to lock - * @returns `true` if lock acquired, `false` if already locked - */ - private tryLock(key: T): boolean { - if (this.locked.has(key)) { - return false; - } - - this.locked.add(key); - - return true; - } - - /** - * Waits to acquire a lock, blocking until available. - * Operations are queued in FIFO order. Must call `unlock()` when done. - * - * @param key The key to wait for and lock - * @returns Promise that resolves when lock is acquired - */ - private async waitForLock(key: T): Promise { - if (this.tryLock(key)) { - return Promise.resolve(); - } - - this.logger?.debug(`Waiting for lock on ${key}`); - - return new Promise((resolve) => { - // DefaultDict behavior - let waiting = this.waiters.get(key); - if (!waiting) { - waiting = []; - this.waiters.set(key, waiting); - } - - waiting.push(resolve); - }); - } - - /** - * Releases a lock and grants access to the next waiting operation in FIFO order. - * Removes the key from locked set if no waiters. - * - * @param key The key to unlock - * @throws {Error} If key is not currently locked - */ - private unlock(key: T): void { - if (!this.locked.has(key)) { - throw new Error(`Key '${key}' is not locked, cannot unlock`); - } - - // Remove first waiter to ensure FIFO order - const nextWaiting = this.waiters.get(key)?.shift(); - - if (nextWaiting) { - this.logger?.debug(`Granted lock on ${key}`); - nextWaiting(); - } else { - this.locked.delete(key); - } - } -} - -export class Lock { - private readonly locks: Locks; - - public constructor(logger?: Logger) { - this.locks = new Locks(logger); - } - - public async withLock(fn: () => R | Promise): Promise { - return this.locks.withLock(true, fn); - } -} diff --git a/frontend/sync-client/src/utils/min-covered.test.ts b/frontend/sync-client/src/utils/min-covered.test.ts deleted file mode 100644 index 82f792c3..00000000 --- a/frontend/sync-client/src/utils/min-covered.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { CoveredValues } from "./min-covered"; - -describe("CoveredValues", () => { - it("should initialize with the given min value", () => { - const covered = new CoveredValues(5); - assert.strictEqual(covered.min, 5); - }); - - it("should add values greater than min", () => { - const covered = new CoveredValues(0); - covered.add(3); - assert.strictEqual(covered.min, 0); - covered.add(1); - assert.strictEqual(covered.min, 1); - covered.add(4); - assert.strictEqual(covered.min, 1); - covered.add(2); - assert.strictEqual(covered.min, 4); - }); - - it("should ignore duplicate values", () => { - const covered = new CoveredValues(0); - covered.add(3); - covered.add(3); - covered.add(3); - assert.strictEqual(covered.min, 0); - covered.add(1); - covered.add(2); - assert.strictEqual(covered.min, 3); - }); - - it("should handle multiple consecutive values", () => { - const covered = new CoveredValues(132); - for (let i = 250; i > 132; i--) { - assert.strictEqual(covered.min, 132); - covered.add(i); - } - assert.strictEqual(covered.min, 250); - }); - - it("should handle adding values lower than current min", () => { - const covered = new CoveredValues(5); - covered.add(3); - assert.strictEqual(covered.min, 5); - covered.add(6); - assert.strictEqual(covered.min, 6); - }); - - it("should handle force setting min value", () => { - const covered = new CoveredValues(5); - covered.add(7); - covered.add(8); - covered.add(9); - assert.strictEqual(covered.min, 5); - covered.min = 6; - assert.strictEqual(covered.min, 6); - covered.add(10); - assert.strictEqual(covered.min, 10); - }); -}); diff --git a/frontend/sync-client/src/utils/min-covered.ts b/frontend/sync-client/src/utils/min-covered.ts deleted file mode 100644 index c453ef88..00000000 --- a/frontend/sync-client/src/utils/min-covered.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * A class that tracks the minimum covered value in a sequence of numbers. - * It keeps track of a minimum value based on the seen values. - * - * It expects integers slightly out of order and makes sure that the value of `min` is - * always the minimum of the seen values. This is done with bounded memory usage. - * - * @example - * ```typescript - * const covered = new CoveredValues(0); - * covered.add(2); // seenValues = [2], min = 0 - * covered.add(1); // seenValues = [], min = 2 - * covered.min; // returns 2 - * ``` - */ -export class CoveredValues { - private seenValues: number[] = []; - - public constructor(private minValue: number) {} - - public get min(): number { - return this.minValue; - } - - public set min(value: number) { - this.minValue = Math.max(value, this.minValue); - this.seenValues = this.seenValues.filter((v) => v > value); - } - - public add(value: number): void { - if (value < this.minValue) { - return; - } - - let i = 0; - while (i < this.seenValues.length && this.seenValues[i] < value) { - i++; - } - - if (i === this.seenValues.length) { - this.seenValues.push(value); - } else if (this.seenValues[i] === value) { - return; - } else { - this.seenValues.splice(i, 0, value); - } - - while ( - this.seenValues.length > 0 && - this.seenValues[0] === this.minValue + 1 - ) { - this.seenValues.shift(); - this.minValue++; - } - } -} diff --git a/frontend/sync-client/src/utils/position-to-line-and-column.test.ts b/frontend/sync-client/src/utils/position-to-line-and-column.test.ts index bc21b983..2797bd8e 100644 --- a/frontend/sync-client/src/utils/position-to-line-and-column.test.ts +++ b/frontend/sync-client/src/utils/position-to-line-and-column.test.ts @@ -3,64 +3,86 @@ import assert from "node:assert"; import { positionToLineAndColumn } from "./position-to-line-and-column"; describe("positionToLineAndColumn", () => { - test("converts position to line and column in multi-line text", () => { - const text = "ab\ncd\n"; - assert.deepStrictEqual(positionToLineAndColumn(text, 0), { - line: 0, - column: 0 - }); - assert.deepStrictEqual(positionToLineAndColumn(text, 1), { - line: 0, - column: 1 - }); - assert.deepStrictEqual(positionToLineAndColumn(text, 2), { - line: 0, - column: 2 - }); - assert.deepStrictEqual(positionToLineAndColumn(text, 3), { - line: 1, - column: 0 - }); - assert.deepStrictEqual(positionToLineAndColumn(text, 4), { - line: 1, - column: 1 - }); - assert.deepStrictEqual(positionToLineAndColumn(text, 6), { - line: 2, - column: 0 - }); - }); + test("converts position to line and column in multi-line text", () => { + const text = "ab\ncd\n"; + assert.deepStrictEqual(positionToLineAndColumn(text, 0), { + line: 0, + column: 0 + }); + assert.deepStrictEqual(positionToLineAndColumn(text, 1), { + line: 0, + column: 1 + }); + assert.deepStrictEqual(positionToLineAndColumn(text, 2), { + line: 0, + column: 2 + }); + assert.deepStrictEqual(positionToLineAndColumn(text, 3), { + line: 1, + column: 0 + }); + assert.deepStrictEqual(positionToLineAndColumn(text, 4), { + line: 1, + column: 1 + }); + assert.deepStrictEqual(positionToLineAndColumn(text, 6), { + line: 2, + column: 0 + }); + }); - test("with carrige returns", () => { - assert.deepStrictEqual(positionToLineAndColumn("a\nb", 3), { - line: 1, - column: 1 - }); + test("with carrige returns", () => { + assert.deepStrictEqual(positionToLineAndColumn("a\nb", 3), { + line: 1, + column: 1 + }); - assert.deepStrictEqual(positionToLineAndColumn("a\r\nb", 3), { - line: 1, - column: 1 - }); - }); + assert.deepStrictEqual(positionToLineAndColumn("a\r\nb", 3), { + line: 1, + column: 1 + }); + }); - test("handles empty input", () => { - assert.deepStrictEqual(positionToLineAndColumn("", 0), { - line: 0, - column: 0 - }); - }); + test("with multiple carriage returns", () => { + // Test that all \r characters are removed, not just the first one + const text = "line1\r\nline2\r\nline3\r\n"; - test("handles positions at the end of text", () => { - const text = "End"; - assert.deepStrictEqual(positionToLineAndColumn(text, 3), { - line: 0, - column: 3 - }); - }); + assert.deepStrictEqual(positionToLineAndColumn(text, 0), { + line: 0, + column: 0 + }); - test("throws error for position out of range", () => { - const text = "Short text"; - assert.throws(() => positionToLineAndColumn(text, 15)); - assert.throws(() => positionToLineAndColumn(text, -1)); - }); + // Position 6 = start of 'line2' after all \r removed + assert.deepStrictEqual(positionToLineAndColumn(text, 6), { + line: 1, + column: 0 + }); + + // Position 12 = start of 'line3' after all \r removed + assert.deepStrictEqual(positionToLineAndColumn(text, 12), { + line: 2, + column: 0 + }); + }); + + test("handles empty input", () => { + assert.deepStrictEqual(positionToLineAndColumn("", 0), { + line: 0, + column: 0 + }); + }); + + test("handles positions at the end of text", () => { + const text = "End"; + assert.deepStrictEqual(positionToLineAndColumn(text, 3), { + line: 0, + column: 3 + }); + }); + + test("throws error for position out of range", () => { + const text = "Short text"; + assert.throws(() => positionToLineAndColumn(text, 15)); + assert.throws(() => positionToLineAndColumn(text, -1)); + }); }); diff --git a/frontend/sync-client/src/utils/position-to-line-and-column.ts b/frontend/sync-client/src/utils/position-to-line-and-column.ts index 3df61ded..969171d8 100644 --- a/frontend/sync-client/src/utils/position-to-line-and-column.ts +++ b/frontend/sync-client/src/utils/position-to-line-and-column.ts @@ -7,30 +7,27 @@ * @throws Will throw an error if the position is negative or exceeds the text length */ export function positionToLineAndColumn( - text: string, - position: number + text: string, + position: number ): { line: number; column: number } { - if (position < 0) { - throw new Error("Position cannot be negative"); - } + if (position < 0) { + throw new Error("Position cannot be negative"); + } - text = text.replace("\r", ""); + text = text.replaceAll("\r", ""); - if ( - position > - text.length + 1 - // +1 to account for the cursor being after last character - ) { - throw new Error( - `Position ${position} exceeds text length ${text.length}` - ); - } + if (position > text.length) { + // position == text.length accounts for the cursor being after last character + throw new Error( + `Position ${position} exceeds text length ${text.length}` + ); + } - const textUpToPosition = text.substring(0, position); - const lines = textUpToPosition.split("\n"); + const textUpToPosition = text.substring(0, position); + const lines = textUpToPosition.split("\n"); - const line = lines.length - 1; - const column = lines[lines.length - 1].length; + const line = lines.length - 1; + const column = lines[lines.length - 1].length; - return { line, column }; + return { line, column }; } diff --git a/frontend/sync-client/src/utils/rate-limit.test.ts b/frontend/sync-client/src/utils/rate-limit.test.ts index e0b77dc4..6c7fb434 100644 --- a/frontend/sync-client/src/utils/rate-limit.test.ts +++ b/frontend/sync-client/src/utils/rate-limit.test.ts @@ -3,62 +3,62 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; describe("rateLimit", () => { - beforeEach(() => { - mock.timers.enable({ apis: ["setTimeout"] }); - }); + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); - afterEach(() => { - mock.timers.reset(); - }); + afterEach(() => { + mock.timers.reset(); + }); - it("should call the function immediately on first invocation", async () => { - const mockFn = mock.fn<() => Promise>(); - mockFn.mock.mockImplementation(async () => "result"); - const rateLimited = rateLimit(mockFn, 100); + it("should call the function immediately on first invocation", async () => { + const mockFn = mock.fn<() => Promise>(); + mockFn.mock.mockImplementation(async () => "result"); + const rateLimited = rateLimit(mockFn, 100); - const promise = rateLimited(); - assert.strictEqual(mockFn.mock.callCount(), 1); + const promise = rateLimited(); + assert.strictEqual(mockFn.mock.callCount(), 1); - await promise; - }); + await promise; + }); - it("should call the function again after the interval has passed", async () => { - const mockFn = mock.fn<(value: number) => Promise>(); - mockFn.mock.mockImplementation(async () => "result"); + it("should call the function again after the interval has passed", async () => { + const mockFn = mock.fn<(value: number) => Promise>(); + mockFn.mock.mockImplementation(async () => "result"); - const rateLimited = rateLimit(mockFn, 100); + const rateLimited = rateLimit(mockFn, 100); - const promise1 = rateLimited(1); - await promise1; + const promise1 = rateLimited(1); + await promise1; - mock.timers.tick(200); + mock.timers.tick(200); - const promise2 = rateLimited(2); - await promise2; + const promise2 = rateLimited(2); + await promise2; - assert.strictEqual(mockFn.mock.callCount(), 2); - assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [2]); - }); + assert.strictEqual(mockFn.mock.callCount(), 2); + assert.deepStrictEqual(mockFn.mock.calls[1].arguments, [2]); + }); - it("should use the most recent arguments if multiple calls are made within interval", async () => { - const mockFn = mock.fn<(value: string) => Promise>(); - mockFn.mock.mockImplementation(async (val: string) => `${val}-result`); - const rateLimited = rateLimit(mockFn, 100); + it("should use the most recent arguments if multiple calls are made within interval", async () => { + const mockFn = mock.fn<(value: string) => Promise>(); + mockFn.mock.mockImplementation(async (val: string) => `${val}-result`); + const rateLimited = rateLimit(mockFn, 100); - const promise1 = rateLimited("first"); - mock.timers.tick(10); - const promise2 = rateLimited("second"); - mock.timers.tick(10); - const promise3 = rateLimited("third"); + const promise1 = rateLimited("first"); + mock.timers.tick(10); + const promise2 = rateLimited("second"); + mock.timers.tick(10); + const promise3 = rateLimited("third"); - mock.timers.tick(1000); + mock.timers.tick(1000); - assert.strictEqual(await promise1, "first-result"); - assert.strictEqual(await promise2, "third-result"); - assert.strictEqual(await promise3, undefined); + assert.strictEqual(await promise1, "first-result"); + assert.strictEqual(await promise2, "third-result"); + assert.strictEqual(await promise3, undefined); - assert.strictEqual(mockFn.mock.callCount(), 2); - assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ["first"]); - assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ["third"]); - }); + assert.strictEqual(mockFn.mock.callCount(), 2); + assert.deepStrictEqual(mockFn.mock.calls[0].arguments, ["first"]); + assert.deepStrictEqual(mockFn.mock.calls[1].arguments, ["third"]); + }); }); diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 4de89ae8..52cbbce7 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -10,49 +10,54 @@ import { sleep } from "./sleep"; * * @template T - Type of the function to be rate limited * @param {T} fn - The asynchronous function to rate limit - * @param {number} minIntervalMs - The minimum interval in milliseconds between function calls + * @param {number | (() => number)} minIntervalMs - Minimum interval in milliseconds between calls, + * or a function that returns the minimum interval * @returns {(...args: Parameters) => ReturnType | Promise} A decorated function that respects the rate limit. * Returns the original function's return type when executed, or undefined if the call was superseded by a newer one. */ export function rateLimit< - R, - T extends ( - ...args: any // eslint-disable-line @typescript-eslint/no-explicit-any - ) => Promise + R, + T extends ( + ...args: any // eslint-disable-line @typescript-eslint/no-explicit-any + ) => Promise >( - fn: T, - minIntervalMs: number + fn: T, + minIntervalMs: number | (() => number) ): (...args: Parameters) => Promise { - let newArgs: Parameters | undefined = undefined; - let running: Promise | undefined = undefined; + let newArgs: Parameters | undefined = undefined; + let running: Promise | undefined = undefined; - const decoratedFn = async ( - ...args: Parameters - ): Promise => { - if (running !== undefined) { - newArgs = args; - await running; + const decoratedFn = async ( + ...args: Parameters + ): Promise => { + if (running !== undefined) { + newArgs = args; + await running; - // args might have changed while we were waiting - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (newArgs === undefined) { - // we weren't the first one to wake up, that means a newer - // invocation is running now, we can just bail - return; - } - args = newArgs; - newArgs = undefined; - } + // args might have changed while we were waiting + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (newArgs === undefined) { + // we weren't the first one to wake up, that means a newer + // invocation is running now, we can just bail + return; + } + args = newArgs; + newArgs = undefined; + } - const [promise, resolve] = createPromise(); - running = promise; - sleep(minIntervalMs) - .then(resolve) - .catch(() => { - // sleep cannot fail - }); - return fn(...args); - }; + const [promise, resolve] = createPromise(); + running = promise; + sleep( + typeof minIntervalMs === "function" + ? minIntervalMs() + : minIntervalMs + ) + .then(resolve) + .catch(() => { + // sleep cannot fail + }); + return fn(...args); + }; - return decoratedFn; + return decoratedFn; } diff --git a/frontend/sync-client/src/utils/remove-from-array.ts b/frontend/sync-client/src/utils/remove-from-array.ts new file mode 100644 index 00000000..393b062f --- /dev/null +++ b/frontend/sync-client/src/utils/remove-from-array.ts @@ -0,0 +1,17 @@ +/** + * Efficiently removes a specific item from an array by modifying it in place. + * This is more efficient than using `.filter(item => item !== toRemove)` as it avoids creating a new array + * + * @param array The array to modify + * @param item The item to remove + * @returns true if the item was found and removed, false otherwise + */ +export function removeFromArray(array: T[], item: T): boolean { + const index = array.indexOf(item); + if (index !== -1) { + // eslint-disable-next-line no-restricted-syntax -- This is the implementation of the helper itself + array.splice(index, 1); + return true; + } + return false; +} diff --git a/frontend/sync-client/src/utils/set-up-telemetry.ts b/frontend/sync-client/src/utils/set-up-telemetry.ts new file mode 100644 index 00000000..d9e73a79 --- /dev/null +++ b/frontend/sync-client/src/utils/set-up-telemetry.ts @@ -0,0 +1,41 @@ +import * as Sentry from "@sentry/browser"; + +// @ts-expect-error, injected by webpack +const packageVersion = __CURRENT_VERSION__; // eslint-disable-line + +export const setUpTelemetry = (): (() => void) => { + Sentry.init({ + dsn: "https://a9bb2b9151bb450ca86b936436e356c4@bugs.schmelczer.dev/1", + release: `sync-client@${packageVersion}`, + sendDefaultPii: true, + integrations: [], + tracesSampleRate: 0 + }); + + Sentry.captureMessage("Initialised telemetry"); + + const onError = (event: ErrorEvent): void => { + Sentry.captureException(event.error, { + extra: { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno + } + }); + }; + window.addEventListener("error", onError); + + const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + Sentry.captureException(event.reason); + }; + window.addEventListener("unhandledrejection", onUnhandledRejection); + + return (): void => { + window.removeEventListener("error", onError); + window.removeEventListener("unhandledrejection", onUnhandledRejection); + Sentry.close(5000).catch(() => { + // Ignore errors during shutdown + }); + }; +}; diff --git a/frontend/sync-client/src/utils/sleep.ts b/frontend/sync-client/src/utils/sleep.ts index 638fc019..ff474799 100644 --- a/frontend/sync-client/src/utils/sleep.ts +++ b/frontend/sync-client/src/utils/sleep.ts @@ -1,3 +1,3 @@ export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/frontend/sync-client/tsconfig.json b/frontend/sync-client/tsconfig.json index c49baa45..92caf072 100644 --- a/frontend/sync-client/tsconfig.json +++ b/frontend/sync-client/tsconfig.json @@ -1,18 +1,18 @@ { - "compilerOptions": { - "module": "ESNext", - "target": "ESNext", - "strict": true, - "allowSyntheticDefaultImports": true, - "moduleResolution": "bundler", - "lib": [ - "DOM", // to get `fetch` & `WebSocket` - "ES2024" - ], - "declaration": true, - "declarationDir": "./dist/types" - }, - "exclude": [ - "./dist" - ] -} \ No newline at end of file + "compilerOptions": { + "module": "ESNext", + "target": "ESNext", + "strict": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "lib": [ + "DOM", // to get `fetch` & `WebSocket` + "ES2024" + ], + "declaration": true, + "declarationDir": "./dist/types" + }, + "exclude": [ + "./dist" + ] +} diff --git a/frontend/sync-client/webpack.config.js b/frontend/sync-client/webpack.config.js index d84a5cd4..b7c3a3fd 100644 --- a/frontend/sync-client/webpack.config.js +++ b/frontend/sync-client/webpack.config.js @@ -4,68 +4,68 @@ const webpack = require("webpack"); const packageJson = require("./package.json"); const common = { - entry: "./src/index.ts", - module: { - rules: [ - { - test: /\.ts$/, - use: ["ts-loader"] - }, - { - test: /\.wasm$/, - type: "asset/inline" - } - ] - }, - plugins: [ - new webpack.DefinePlugin({ - __CURRENT_VERSION__: JSON.stringify(packageJson.version) - }) - ], - optimization: { - // the consuming project should take care of minification - minimize: false - }, - resolve: { - extensions: [".ts", ".js"], - alias: { - root: __dirname, - src: path.resolve(__dirname, "src") - } - }, - performance: { - hints: false // it's a library, no need to warn about its size - } + entry: "./src/index.ts", + module: { + rules: [ + { + test: /\.ts$/, + use: ["ts-loader"] + }, + { + test: /\.wasm$/, + type: "asset/inline" + } + ] + }, + plugins: [ + new webpack.DefinePlugin({ + __CURRENT_VERSION__: JSON.stringify(packageJson.version) + }) + ], + optimization: { + // the consuming project should take care of minification + minimize: false + }, + resolve: { + extensions: [".ts", ".js"], + alias: { + root: __dirname, + src: path.resolve(__dirname, "src") + } + }, + performance: { + hints: false // it's a library, no need to warn about its size + } }; module.exports = [ - merge(common, { - target: "web", - output: { - path: path.resolve(__dirname, "dist"), - filename: "sync-client.web.js", - library: { - name: "SyncClient", - type: "umd" - }, - globalObject: "this" - }, - resolve: { - fallback: { - ws: false // Exclude `ws` from the browser bundle - } - } - }), - merge(common, { - target: "node", - output: { - path: path.resolve(__dirname, "dist"), - filename: "sync-client.node.js", - libraryTarget: "commonjs2" - }, - externals: { - bufferutil: "bufferutil", - "utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733 - } - }) + merge(common, { + target: "web", + output: { + path: path.resolve(__dirname, "dist"), + filename: "sync-client.web.js", + library: { + name: "SyncClient", + type: "umd" + }, + globalObject: "this" + }, + resolve: { + fallback: { + ws: false // Exclude `ws` from the browser bundle + } + } + }), + merge(common, { + target: "node", + output: { + path: path.resolve(__dirname, "dist"), + filename: "sync-client.node.js", + libraryTarget: "commonjs2" + }, + externals: { + bufferutil: "bufferutil", + "utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733 + } + }) ]; diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index aa6aba25..3d0d0c1a 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -1,24 +1,24 @@ { - "name": "test-client", - "version": "0.9.2", - "private": true, - "bin": { - "test-client": "./dist/cli.js" - }, - "scripts": { - "dev": "webpack watch --mode development", - "build": "webpack --mode production", - "test": "tsx --test src/**/*.test.ts" - }, - "devDependencies": { - "@types/node": "^24.8.1", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "tsx": "^4.20.5", - "typescript": "5.8.3", - "uuid": "^11.1.0", - "webpack": "^5.99.9", - "webpack-cli": "^6.0.1" - } + "name": "test-client", + "version": "0.14.0", + "private": true, + "bin": { + "test-client": "./dist/cli.js" + }, + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "tsx --test 'src/**/*.test.ts'" + }, + "devDependencies": { + "@types/node": "^24.8.1", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "uuid": "^13.0.0", + "webpack": "^5.99.9", + "webpack-cli": "^6.0.1" + } } diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index a6ced45d..1640c2ec 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -2,332 +2,367 @@ import { choose } from "../utils/choose"; import { v4 as uuidv4 } from "uuid"; import { assert } from "../utils/assert"; import type { RelativePath, SyncSettings } from "sync-client"; -import { debugging, Logger, LogLevel } from "sync-client"; +import { debugging, Logger, LogLevel, utils } from "sync-client"; import { MockClient } from "./mock-client"; import { sleep } from "../utils/sleep"; -import type { LogLine } from "sync-client/dist/types/tracing/logger"; +import type { LogLine } from "sync-client"; +import { withTimeout } from "../utils/with-timeout"; + +const TIMEOUT_MS = 10 * 60 * 1000; export class MockAgent extends MockClient { - private readonly writtenContents: string[] = []; - private readonly pendingActions: Promise[] = []; + private readonly writtenContents: string[] = []; + private readonly pendingActions: Promise[] = []; - // The renamed file finding algorithm isn't too smart so we can't both update and rename the same file - private doNotTouchWhileOffline: string[] = []; + // The renamed file finding algorithm isn't too smart so we can't both update and rename the same file + private readonly doNotTouchWhileOffline: string[] = []; - public constructor( - initialSettings: Partial, - public readonly name: string, - private readonly doDeletes: boolean, - useSlowFileEvents: boolean, - private readonly jitterScaleInSeconds: number - ) { - super(initialSettings, useSlowFileEvents); - } + public constructor( + initialSettings: Partial, + public readonly name: string, + private readonly doDeletes: boolean, + private readonly doResets: boolean, + useSlowFileEvents: boolean, + private readonly jitterScaleInSeconds: number + ) { + super(initialSettings, useSlowFileEvents); + } - public async init(): Promise { - await super.init( - debugging.slowFetchFactory(this.jitterScaleInSeconds), - debugging.slowWebSocketFactory( - this.jitterScaleInSeconds, - new Logger() // this logger isn't wired anywhere, so messages to it will be ignored - ) - ); + public async init(): Promise { + await super.init( + debugging.slowFetchFactory(this.jitterScaleInSeconds), + debugging.slowWebSocketFactory( + this.jitterScaleInSeconds, + new Logger() // this logger isn't wired anywhere, so messages to it will be ignored + ) + ); - assert( - (await this.client.checkConnection()).isSuccessful, - "Connection check failed" - ); + assert( + (await this.client.checkConnection()).isSuccessful, + "Connection check failed" + ); - this.client.logger.addOnMessageListener((logLine: LogLine) => { - const state = this.client.getSettings().isSyncEnabled - ? "(online) " - : "(offline)"; - const formatted = `[${this.name} ${state}] ${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; + this.client.logger.onLogEmitted.add((logLine: LogLine) => { + const state = this.client.getSettings().isSyncEnabled + ? "(online) " + : "(offline)"; + const formatted = `[${this.name} ${state}] ${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; - // HACK: we have to ensure the file has been synced if we want to change it offline without data loss - const historyEntry = /.*History entry: (.*.md).*/.exec( - logLine.message - ); + // HACK: we have to ensure the file has been synced if we want to change it offline without data loss + const historyEntry = /.*History entry: (.*.md).*/.exec( + logLine.message + ); - if (historyEntry) { - this.doNotTouchWhileOffline = - this.doNotTouchWhileOffline.filter( - (file) => file !== historyEntry[1] - ); - } - switch (logLine.level) { - case LogLevel.ERROR: - console.error(formatted); + if (historyEntry) { + utils.removeFromArray( + this.doNotTouchWhileOffline, + historyEntry[1] + ); + } + switch (logLine.level) { + case LogLevel.ERROR: + console.error(formatted); - if (!this.useSlowFileEvents) { - // Let's wait for the error to be caught if there was one - // eslint-disable-next-line @typescript-eslint/no-floating-promises - sleep(100).then(() => process.exit(1)); - } + if (!this.useSlowFileEvents) { + // Let's wait for the error to be caught if there was one + // eslint-disable-next-line @typescript-eslint/no-floating-promises + sleep(100).then(() => process.exit(1)); + } - break; - case LogLevel.WARNING: - console.warn(formatted); - break; - case LogLevel.INFO: - console.info(formatted); - break; - case LogLevel.DEBUG: - console.debug(formatted); - break; - } - }); + break; + case LogLevel.WARNING: + console.warn(formatted); + break; + case LogLevel.INFO: + console.info(formatted); + break; + case LogLevel.DEBUG: + console.debug(formatted); + break; + } + }); - this.client.logger.info("Agent initialized"); - } + this.client.logger.info("Agent initialized"); + } - public async act(): Promise { - const options: (() => Promise)[] = [ - this.createFileAction.bind(this) - ]; + public async act(): Promise { + const options: (() => Promise)[] = [ + this.createFileAction.bind(this) + ]; - if (this.client.getSettings().isSyncEnabled) { - if (this.doNotTouchWhileOffline.length === 0) { - options.push(this.disableSyncAction.bind(this)); - } - } else { - options.push(this.enableSyncAction.bind(this)); - } + if ( + this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.length === 0 + ) { + options.push(this.disableSyncAction.bind(this)); + } else { + options.push(this.enableSyncAction.bind(this)); + } - const files = await this.listFilesRecursively(); + const files = await this.listFilesRecursively(); - if (files.length > 0) { - options.push( - this.renameFileAction.bind(this, files), - this.updateFileAction.bind(this, files) - ); + if (files.length > 0) { + options.push( + this.renameFileAction.bind(this, files), + this.updateFileAction.bind(this, files) + ); - if (this.doDeletes) { - options.push(this.deleteFileAction.bind(this, files)); - } - } + if (this.doDeletes) { + options.push(this.deleteFileAction.bind(this, files)); + } + } - this.pendingActions.push( - (async (): Promise => { - try { - return await choose(options)(); - } catch (error) { - this.client.logger.error( - `Failed to perform an action: ${error}` - ); - this.client.logger.info(JSON.stringify(this.data, null, 2)); - this.client.logger.info( - JSON.stringify(this.localFiles, null, 2) - ); - throw error; - } - })() - ); - } + if (Math.random() < 0.015 && this.doResets) { + // we can't just queue this up as once it's destroyed, no more method calls can go to SyncClient + await this.resetClient(); + } else { + this.pendingActions.push( + (async (): Promise => { + try { + return await choose(options)(); + } catch (error) { + this.client.logger.error( + `Failed to perform an action: ${error}` + ); + this.client.logger.info( + JSON.stringify(this.data, null, 2) + ); + this.client.logger.info( + JSON.stringify(this.localFiles, null, 2) + ); + throw error; + } + })() + ); + } + } - public async finish(): Promise { - await this.client.setSetting("isSyncEnabled", true); - await Promise.all(this.pendingActions); - await this.client.waitAndStop(); - } + public async finish(): Promise { + await withTimeout( + (async (): Promise => { + await this.client.setSetting("isSyncEnabled", true); + await utils.awaitAll(this.pendingActions); + await this.client.waitUntilFinished(); + })(), + TIMEOUT_MS, + "finish()" + ); + } - public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { - const globalFiles = Array.from(otherAgent.localFiles.keys()); - const localFiles = Array.from(this.localFiles.keys()); + public async destroy(): Promise { + await withTimeout( + (async (): Promise => { + await this.client.waitUntilFinished(); + await this.client.destroy(); + })(), + TIMEOUT_MS, + "destroy()" + ); + } - const missingInOther = localFiles.filter( - (file) => !otherAgent.localFiles.has(file) - ); - const missingInLocal = globalFiles.filter( - (file) => !this.localFiles.has(file) - ); + public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { + const globalFiles = Array.from(otherAgent.localFiles.keys()); + const localFiles = Array.from(this.localFiles.keys()); - try { - assert( - missingInOther.length === 0, - `Files from ${this.name} missing in ${otherAgent.name}: ${missingInOther.join(", ")}` - ); - assert( - missingInLocal.length === 0, - `Files from ${otherAgent.name} missing in ${this.name}: ${missingInLocal.join(", ")}` - ); + const missingInOther = localFiles.filter( + (file) => !otherAgent.localFiles.has(file) + ); + const missingInLocal = globalFiles.filter( + (file) => !this.localFiles.has(file) + ); - for (const file of globalFiles) { - const localContent = new TextDecoder().decode( - this.localFiles.get(file) - ); - const otherContent = new TextDecoder().decode( - otherAgent.localFiles.get(file) - ); - assert( - localContent === otherContent, - `Content mismatch for file ${file}:\n${localContent}\n${otherContent}` - ); - } - } catch (e) { - this.client.logger.info( - "Local data: " + JSON.stringify(this.data, null, 2) - ); - this.client.logger.info( - "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") - ); - otherAgent.client.logger.info( - "Local data: " + JSON.stringify(otherAgent.data, null, 2) - ); - otherAgent.client.logger.info( - "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") - ); + try { + assert( + missingInOther.length === 0, + `Files from ${this.name} missing in ${otherAgent.name}: ${missingInOther.join(", ")}` + ); + assert( + missingInLocal.length === 0, + `Files from ${otherAgent.name} missing in ${this.name}: ${missingInLocal.join(", ")}` + ); - throw e; - } - } + for (const file of globalFiles) { + const localContent = new TextDecoder().decode( + this.localFiles.get(file) + ); + const otherContent = new TextDecoder().decode( + otherAgent.localFiles.get(file) + ); + assert( + localContent === otherContent, + `Content mismatch for file ${file}:\n${localContent}\n${otherContent}` + ); + } + } catch (e) { + this.client.logger.info( + "Local data: " + JSON.stringify(this.data, null, 2) + ); + this.client.logger.info( + "Local files: " + + Array.from(otherAgent.localFiles.keys()).join(", ") + ); + otherAgent.client.logger.info( + "Local data: " + JSON.stringify(otherAgent.data, null, 2) + ); + otherAgent.client.logger.info( + "Local files: " + + Array.from(otherAgent.localFiles.keys()).join(", ") + ); - public assertAllContentIsPresentOnce(): void { - if (this.useSlowFileEvents) { - this.client.logger.info( - // We can't ensure that we have seen every single update - `Skipping content check for ${this.name} because slow file events are enabled` - ); - return; - } + throw e; + } + } - for (const content of this.writtenContents) { - const found = Array.from(this.localFiles.keys()).filter((key) => { - return new TextDecoder() - .decode(this.localFiles.get(key)) - .includes(content); - }); + public assertAllContentIsPresentOnce(): void { + if (this.useSlowFileEvents) { + this.client.logger.info( + // We can't ensure that we have seen every single update + `Skipping content check for ${this.name} because slow file events are enabled` + ); + return; + } - if (this.doDeletes) { - assert( - found.length <= 1, - `[${this.name}] Content ${content} found in ${found.join(", ")}` - ); - } else { - assert( - found.length >= 1, - `[${this.name}] Content ${content} not found in any files` - ); + for (const content of this.writtenContents) { + const found = Array.from(this.localFiles.keys()).filter((key) => { + return new TextDecoder() + .decode(this.localFiles.get(key)) + .includes(content); + }); - assert( - found.length <= 1, - `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` - ); + if (this.doDeletes) { + assert( + found.length <= 1, + `[${this.name}] Content ${content} found in ${found.join(", ")}` + ); + } else { + assert( + found.length >= 1, + `[${this.name}] Content ${content} not found in any files` + ); - const [file] = found; - const fileContent = new TextDecoder().decode( - this.localFiles.get(file) - ); - assert( - fileContent.split(content).length == 2, - `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` - ); - } - } - } + assert( + found.length <= 1, + `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` + ); - private async createFileAction(): Promise { - const file = this.getFileName(); + const [file] = found; + const fileContent = new TextDecoder().decode( + this.localFiles.get(file) + ); + assert( + fileContent.split(content).length == 2, + `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` + ); + } + } + } - if ( - (!this.client.getSettings().isSyncEnabled && - this.doNotTouchWhileOffline.includes(file)) || - (await this.exists(file)) - ) { - return; - } + private async resetClient(): Promise { + this.client.logger.info(`Resetting client ${this.name}`); + await this.client.destroy(); + await this.init(); + } - const content = this.getContent(); - this.client.logger.info( - `Decided to create file ${file} with content ${content}` - ); + private async createFileAction(): Promise { + const file = this.getFileName(); - return this.create(file, new TextEncoder().encode(` ${content} `)); - } + if ( + (!this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(file)) || + (await this.exists(file)) + ) { + return; + } - private async disableSyncAction(): Promise { - this.client.logger.info(`Decided to disable sync`); - await this.client.setSetting("isSyncEnabled", false); - } + const content = this.getContent(); + this.client.logger.info( + `Decided to create file ${file} with content ${content}` + ); - private async enableSyncAction(): Promise { - this.client.logger.info(`Decided to enable sync`); - await this.client.setSetting("isSyncEnabled", true); - } + return this.create(file, new TextEncoder().encode(` ${content} `)); + } - private async renameFileAction(files: RelativePath[]): Promise { - const file = choose(files); + private async disableSyncAction(): Promise { + this.client.logger.info(`Decided to disable sync`); + await this.client.setSetting("isSyncEnabled", false); + } - // We can't edit files offline that have been updated while offline. - // Otherwise, the resolution logic couldn't handle it. - if ( - !this.client.getSettings().isSyncEnabled && - this.doNotTouchWhileOffline.includes(file) - ) { - this.client.logger.info( - `Skipping file ${file} because it has been updated while offline` - ); - return; - } + private async enableSyncAction(): Promise { + this.client.logger.info(`Decided to enable sync`); + await this.client.setSetting("isSyncEnabled", true); + } - const newName = this.getFileName(); + private async renameFileAction(files: RelativePath[]): Promise { + const file = choose(files); - if ( - (!this.client.getSettings().isSyncEnabled && - this.doNotTouchWhileOffline.includes(newName)) || - (await this.exists(newName)) - ) { - return; - } + // We can't edit files offline that have been updated while offline. + // Otherwise, the resolution logic couldn't handle it. + if ( + !this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(file) + ) { + this.client.logger.info( + `Skipping file ${file} because it has been updated while offline` + ); + return; + } - this.client.logger.info(`Decided to rename file ${file} to ${newName}`); - this.doNotTouchWhileOffline.push(file, newName); + const newName = this.getFileName(); - return this.rename(file, newName); - } + if ( + (!this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(newName)) || + (await this.exists(newName)) + ) { + return; + } - private async updateFileAction(files: RelativePath[]): Promise { - const file = choose(files); + this.client.logger.info(`Decided to rename file ${file} to ${newName}`); + this.doNotTouchWhileOffline.push(file, newName); - // We can't edit files offline that have been updated while offline. - // Otherwise, the resolution logic couldn't handle it. - if ( - !this.client.getSettings().isSyncEnabled && - this.doNotTouchWhileOffline.includes(file) - ) { - this.client.logger.info( - `Skipping file ${file} because it has been updated while offline` - ); - return; - } + return this.rename(file, newName); + } - const content = this.getContent(); - this.client.logger.info( - `Decided to update file ${file} with ${content}` - ); - this.doNotTouchWhileOffline.push(file); - await this.atomicUpdateText(file, (old) => ({ - text: old.text + ` ${content} `, - cursors: [] - })); - } + private async updateFileAction(files: RelativePath[]): Promise { + const file = choose(files); - private async deleteFileAction(files: RelativePath[]): Promise { - const file = choose(files); - this.client.logger.info(`Decided to delete file ${file}`); - return this.delete(file); - } + // We can't edit files offline that have been updated while offline. + // Otherwise, the resolution logic couldn't handle it. + if ( + !this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(file) + ) { + this.client.logger.info( + `Skipping file ${file} because it has been updated while offline` + ); + return; + } - private getContent(): string { - const uuid = uuidv4(); - this.writtenContents.push(uuid); - return uuid; - } + const content = this.getContent(); + this.client.logger.info( + `Decided to update file ${file} with ${content}` + ); + this.doNotTouchWhileOffline.push(file); + await this.atomicUpdateText(file, (old) => ({ + text: old.text + ` ${content} `, + cursors: [] + })); + } - private getFileName(): string { - // Simulate name collisions between the clients - return `file-${Math.floor(Math.random() * 64)}.md`; - } + private async deleteFileAction(files: RelativePath[]): Promise { + const file = choose(files); + this.client.logger.info(`Decided to delete file ${file}`); + return this.delete(file); + } + + private getContent(): string { + const uuid = uuidv4(); + this.writtenContents.push(uuid); + return uuid; + } + + private getFileName(): string { + // Simulate name collisions between the clients + return `file-${Math.floor(Math.random() * 64)}.md`; + } } diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 2b384c24..c814879a 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -1,198 +1,197 @@ -import type { StoredDatabase } from "sync-client"; +import type { StoredDatabase, TextWithCursors } from "sync-client"; import { assert } from "../utils/assert"; import { - type RelativePath, - type FileSystemOperations, - type SyncSettings, - SyncClient + type RelativePath, + type FileSystemOperations, + type SyncSettings, + SyncClient } from "sync-client"; -import type { TextWithCursors } from "reconcile-text"; export class MockClient implements FileSystemOperations { - protected readonly localFiles = new Map(); - protected client!: SyncClient; + protected readonly localFiles = new Map(); + protected client!: SyncClient; - protected data: Partial<{ - settings: Partial; - database: Partial; - }> = { - database: { - // Assume all clients start at the same time so there's no need to fetch - // any shared state. - hasInitialSyncCompleted: true - } - }; + protected data: Partial<{ + settings: Partial; + database: Partial; + }> = { + database: { + // Assume all clients start at the same time so there's no need to fetch + // any shared state. + hasInitialSyncCompleted: true + } + }; - public constructor( - initialSettings: Partial, - protected readonly useSlowFileEvents: boolean - ) { - this.data.settings = initialSettings; - } + public constructor( + initialSettings: Partial, + protected readonly useSlowFileEvents: boolean + ) { + this.data.settings = initialSettings; + } - public async init( - fetchImplementation: typeof globalThis.fetch, - webSocketImplementation: typeof globalThis.WebSocket - ): Promise { - this.client = await SyncClient.create({ - fs: this, - persistence: { - load: async () => this.data, - save: async (data) => void (this.data = data) - }, - fetch: fetchImplementation, - webSocket: webSocketImplementation - }); + public async init( + fetchImplementation: typeof globalThis.fetch, + webSocketImplementation: typeof globalThis.WebSocket + ): Promise { + this.client = await SyncClient.create({ + fs: this, + persistence: { + load: async () => this.data, + save: async (data) => void (this.data = data) + }, + fetch: fetchImplementation, + webSocket: webSocketImplementation + }); - await this.client.start(); - } + await this.client.start(); + } - public async listFilesRecursively( - _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests - ): Promise { - return Array.from(this.localFiles.keys()); - } + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { + return Array.from(this.localFiles.keys()); + } - public async read(path: RelativePath): Promise { - const file = this.localFiles.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - return file; - } + public async read(path: RelativePath): Promise { + const file = this.localFiles.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + return file; + } - public async getFileSize(path: RelativePath): Promise { - return (await this.read(path)).length; - } + public async getFileSize(path: RelativePath): Promise { + return (await this.read(path)).length; + } - public async exists(path: RelativePath): Promise { - return this.localFiles.has(path); - } + public async exists(path: RelativePath): Promise { + return this.localFiles.has(path); + } - public async create( - path: RelativePath, - newContent: Uint8Array - ): Promise { - if (this.localFiles.has(path)) { - throw new Error(`File ${path} already exists`); - } - this.client.logger.info( - `Creating file ${path} with content ${new TextDecoder().decode(newContent)}` - ); - this.localFiles.set(path, newContent); + public async create( + path: RelativePath, + newContent: Uint8Array + ): Promise { + if (this.localFiles.has(path)) { + throw new Error(`File ${path} already exists`); + } + this.client.logger.info( + `Creating file ${path} with content ${new TextDecoder().decode(newContent)}` + ); + this.localFiles.set(path, newContent); - this.executeFileOperation(async () => - this.client.syncLocallyCreatedFile(path) - ); - } + this.executeFileOperation(async () => + this.client.syncLocallyCreatedFile(path) + ); + } - public async createDirectory(_path: RelativePath): Promise { - // This doesn't mean anything in our virtual FS representation - } + public async createDirectory(_path: RelativePath): Promise { + // This doesn't mean anything in our virtual FS representation + } - public async atomicUpdateText( - path: RelativePath, - updater: (currentContent: TextWithCursors) => TextWithCursors - ): Promise { - const file = this.localFiles.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - const currentContent = new TextDecoder().decode(file); - const newContent = updater({ text: currentContent, cursors: [] }).text; - const newContentUint8Array = new TextEncoder().encode(newContent); - this.localFiles.set(path, newContentUint8Array); + public async atomicUpdateText( + path: RelativePath, + updater: (currentContent: TextWithCursors) => TextWithCursors + ): Promise { + const file = this.localFiles.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + const currentContent = new TextDecoder().decode(file); + const newContent = updater({ text: currentContent, cursors: [] }).text; + const newContentUint8Array = new TextEncoder().encode(newContent); + this.localFiles.set(path, newContentUint8Array); - if (!this.useSlowFileEvents) { - const existingParts = currentContent - .split(" ") - .map((part) => part.trim()); - const newParts = newContent.split(" ").map((part) => part.trim()); - existingParts.forEach((part) => - // all changes should be additive - { - assert( - newParts.includes(part), - `Part ${part} not found in new content: ${newContent}` - ); - } - ); - } + if (!this.useSlowFileEvents) { + const existingParts = currentContent + .split(" ") + .map((part) => part.trim()); + const newParts = newContent.split(" ").map((part) => part.trim()); + existingParts.forEach((part) => + // all changes should be additive + { + assert( + newParts.includes(part), + `Part ${part} not found in new content: ${newContent}` + ); + } + ); + } - this.client.logger.info( - `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` - ); + this.client.logger.info( + `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` + ); - this.executeFileOperation(async () => - this.client.syncLocallyUpdatedFile({ - relativePath: path - }) - ); + this.executeFileOperation(async () => + this.client.syncLocallyUpdatedFile({ + relativePath: path + }) + ); - return newContent; - } + return newContent; + } - public async write(path: RelativePath, content: Uint8Array): Promise { - const hasExisted = this.localFiles.has(path); - this.localFiles.set(path, content); + public async write(path: RelativePath, content: Uint8Array): Promise { + const hasExisted = this.localFiles.has(path); + this.localFiles.set(path, content); - this.client.logger.info( - `Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` - ); + this.client.logger.info( + `Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` + ); - this.executeFileOperation(async () => { - if (hasExisted) { - return this.client.syncLocallyUpdatedFile({ - relativePath: path - }); - } else { - return this.client.syncLocallyCreatedFile(path); - } - }); - } + this.executeFileOperation(async () => { + if (hasExisted) { + return this.client.syncLocallyUpdatedFile({ + relativePath: path + }); + } else { + return this.client.syncLocallyCreatedFile(path); + } + }); + } - public async delete(path: RelativePath): Promise { - this.client.logger.info( - `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` - ); - this.localFiles.delete(path); + public async delete(path: RelativePath): Promise { + this.client.logger.info( + `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` + ); + this.localFiles.delete(path); - this.executeFileOperation(async () => - this.client.syncLocallyDeletedFile(path) - ); - } + this.executeFileOperation(async () => + this.client.syncLocallyDeletedFile(path) + ); + } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - const file = this.localFiles.get(oldPath); - if (!file) { - throw new Error(`File ${oldPath} does not exist`); - } - this.localFiles.set(newPath, file); - if (oldPath !== newPath) { - this.localFiles.delete(oldPath); - } + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + const file = this.localFiles.get(oldPath); + if (!file) { + throw new Error(`File ${oldPath} does not exist`); + } + this.localFiles.set(newPath, file); + if (oldPath !== newPath) { + this.localFiles.delete(oldPath); + } - this.client.logger.info( - `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` - ); + this.client.logger.info( + `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` + ); - this.executeFileOperation(async () => - this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath - }) - ); - } + this.executeFileOperation(async () => + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }) + ); + } - private executeFileOperation(callback: () => unknown): void { - if (this.useSlowFileEvents) { - // we aren't the best client and it takes some time to notice changes - setTimeout(callback, Math.random() * 100); - } else { - callback(); - } - } + private executeFileOperation(callback: () => unknown): void { + if (this.useSlowFileEvents) { + // we aren't the best client and it takes some time to notice changes + setTimeout(callback, Math.random() * 100); + } else { + callback(); + } + } } diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 4a3aab4f..3af547e7 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -1,4 +1,5 @@ import type { SyncSettings } from "sync-client"; +import { utils } from "sync-client"; import { MockAgent } from "./agent/mock-agent"; import { sleep } from "./utils/sleep"; import { v4 as uuidv4 } from "uuid"; @@ -9,165 +10,196 @@ const TEST_ITERATIONS = 5; // Simulate async file access by injecting waiting time before returning from file operations. let slowFileEvents = false; +// Whether to do resets in the test runs +let doResets = false; + async function runTest({ - agentCount, - concurrency, - iterations, - doDeletes, - useSlowFileEvents, - jitterScaleInSeconds + agentCount, + concurrency, + iterations, + doDeletes, + useResets, + useSlowFileEvents, + jitterScaleInSeconds }: { - agentCount: number; - concurrency: number; - iterations: number; - doDeletes: boolean; - useSlowFileEvents: boolean; - jitterScaleInSeconds: number; + agentCount: number; + concurrency: number; + iterations: number; + doDeletes: boolean; + useResets: boolean; + useSlowFileEvents: boolean; + jitterScaleInSeconds: number; }): Promise { - slowFileEvents = useSlowFileEvents; + slowFileEvents = useSlowFileEvents; + doResets = useResets; - const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; - console.info(`Running test ${settings}`); + const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; + console.info(`Running test ${settings}`); - const vaultName = uuidv4(); - console.info(`Using vault name: ${vaultName}`); - const initialSettings: Partial = { - isSyncEnabled: true, - token: " test-token-change-me ", // same as in sync-server/config-e2e.yml with spaces - vaultName: randomCasing(vaultName) + (Math.random() > 0.5 ? " " : ""), // extra spaces shouldn't matter - syncConcurrency: concurrency, - remoteUri: "http://localhost:3000" - }; + const vaultName = uuidv4(); + console.info(`Using vault name: ${vaultName}`); + const initialSettings: Partial = { + isSyncEnabled: true, + token: " test-token-change-me ", // same as in sync-server/config-e2e.yml with spaces + vaultName: randomCasing(vaultName) + (Math.random() > 0.5 ? " " : ""), // extra spaces shouldn't matter + syncConcurrency: concurrency, + remoteUri: "http://localhost:3000" + }; - const clients: MockAgent[] = []; - for (let i = 0; i < agentCount; i++) { - clients.push( - new MockAgent( - initialSettings, - `agent-${i}`, - doDeletes, - useSlowFileEvents, - jitterScaleInSeconds - ) - ); - } + const clients: MockAgent[] = []; + for (let i = 0; i < agentCount; i++) { + clients.push( + new MockAgent( + initialSettings, + `agent-${i}`, + doDeletes, + useResets, + useSlowFileEvents, + jitterScaleInSeconds + ) + ); + } - try { - await Promise.all(clients.map(async (client) => client.init())); + try { + await utils.awaitAll(clients.map(async (client) => client.init())); - for (let i = 0; i < iterations; i++) { - console.info(`Iteration ${i + 1}/${iterations}`); - await Promise.all(clients.map(async (client) => client.act())); - await sleep(100); - } + for (let i = 0; i < iterations; i++) { + console.info(`Iteration ${i + 1}/${iterations}`); + await utils.awaitAll(clients.map(async (client) => client.act())); + await sleep(Math.random() * 200); + } - console.info("Stopping agents"); + console.info("Stopping agents"); - // Each agent can have unpushed changes which might conflict with eachother so each has to resolve the conflicts & push, and - for (const client of clients) { - try { - await client.finish(); - } catch (err) { - if (!slowFileEvents) { - throw err; - } - } - } + // Each agent can have unpushed changes which might conflict with eachother so each has to resolve the conflicts & push, and + for (const client of clients) { + try { + console.info(`Finishing up ${client.name}`); + await client.finish(); + } catch (err) { + if (!slowFileEvents) { + throw err; + } + } + } - // then we need a second pass to ensure that all agents pull the same state. - for (const client of clients) { - try { - await client.finish(); - } catch (err) { - if (!slowFileEvents) { - throw err; - } - } - } + // then we need a second pass to ensure that all agents pull the same state. + for (const client of clients) { + try { + console.info(`Destroying ${client.name}`); + await client.destroy(); + } catch (err) { + if (!slowFileEvents) { + throw err; + } + } + } - console.info("Agents finished successfully"); + console.info("Agents finished successfully"); - clients.slice(0, -1).forEach((client, i) => { - console.info( - `Checking consistency between ${client.name} and ${clients[i + 1].name}` - ); - client.assertFileSystemsAreConsistent(clients[i]); - console.info(`Consistency check for ${client.name} passed`); - }); + clients.slice(0, -1).forEach((client, i) => { + console.info( + `Checking consistency between ${client.name} and ${clients[i + 1].name}` + ); + client.assertFileSystemsAreConsistent(clients[i]); + console.info(`Consistency check for ${client.name} passed`); + }); - console.info("File systems found to be consistent"); + console.info("File systems found to be consistent"); - clients.forEach((client) => { - console.info(`Checking content for ${client.name}`); - client.assertAllContentIsPresentOnce(); - console.info(`Content check for ${client.name} passed`); - }); + clients.forEach((client) => { + console.info(`Checking content for ${client.name}`); + client.assertAllContentIsPresentOnce(); + console.info(`Content check for ${client.name} passed`); + }); - console.info(`Test passed ${settings}`); - } catch (err) { - console.error(`Test failed ${settings}`); - throw err; - } + console.info(`Test passed ${settings}`); + } catch (err) { + console.error(`Test failed ${settings}`); + throw err; + } } async function runTests(): Promise { - for (let i = 0; i < TEST_ITERATIONS; i++) { - for (const useSlowFileEvents of [false, true]) { - for (const concurrency of [ - 16, - 1 // test with concurrency 1 to check for deadlocks - ]) { - for (const doDeletes of [true, false]) { - await runTest({ - agentCount: 2, - concurrency, - iterations: 100, - doDeletes, - useSlowFileEvents, - jitterScaleInSeconds: 0.75 - }); - } - } - } - } + for (let i = 0; i < TEST_ITERATIONS; i++) { + await runTest({ + agentCount: 2, + concurrency: 16, + iterations: 100, + doDeletes: true, + useResets: true, + useSlowFileEvents: true, + jitterScaleInSeconds: 0.75 + }); + + for (const useSlowFileEvents of [true, false]) { + for (const concurrency of [ + 16, + 1 // test with concurrency 1 to check for deadlocks + ]) { + for (const doDeletes of [false, true]) { + await runTest({ + agentCount: 2, + concurrency, + iterations: 100, + doDeletes, + useResets: false, + useSlowFileEvents, + jitterScaleInSeconds: 0.75 + }); + } + } + } + } } process.on("uncaughtException", (error) => { - if (slowFileEvents) { - return; - } + if ( + error instanceof Error && + error.message.includes( + "WebSocket was closed before the connection was established" + ) + ) { + return; + } - if ( - error instanceof Error && - error.message.includes( - "WebSocket was closed before the connection was established" - ) - ) { - return; - } - - console.error("Uncaught exception:", error); - process.exit(1); + console.error("Uncaught exception:", error); + process.exit(1); }); process.on("unhandledRejection", (error, _promise) => { - if (error instanceof Error && error.message === "Sync was reset") { - return; - } + if (error instanceof Error && error.message === "Sync was reset") { + return; + } - if (slowFileEvents) { - return; - } + if ( + slowFileEvents && + error instanceof Error && + (error.message.includes("Document not found") || + error.message.includes("Document already exists at new location")) + ) { + return; + } - console.error("Unhandled rejection:", error); - process.exit(1); + if ( + doResets && + error instanceof Error && + error.message.includes( + "SyncClient has been destroyed and can no longer be used" + ) + ) { + return; + } + + console.error("Unhandled rejection:", error); + process.exit(1); }); runTests() - .then(() => { - process.exit(0); - }) - .catch((err: unknown) => { - console.error(err); - process.exit(1); - }); + .then(() => { + process.exit(0); + }) + .catch((err: unknown) => { + console.error(err); + process.exit(1); + }); diff --git a/frontend/test-client/src/utils/assert.ts b/frontend/test-client/src/utils/assert.ts index e1e3bb98..4e709060 100644 --- a/frontend/test-client/src/utils/assert.ts +++ b/frontend/test-client/src/utils/assert.ts @@ -1,5 +1,5 @@ export function assert(value: boolean, message: string): asserts value { - if (!value) { - throw new Error(message); - } + if (!value) { + throw new Error(message); + } } diff --git a/frontend/test-client/src/utils/choose.ts b/frontend/test-client/src/utils/choose.ts index adb1dc7c..09d8339f 100644 --- a/frontend/test-client/src/utils/choose.ts +++ b/frontend/test-client/src/utils/choose.ts @@ -1,3 +1,3 @@ export function choose(values: T[]): T { - return values[Math.floor(Math.random() * values.length)]; + return values[Math.floor(Math.random() * values.length)]; } diff --git a/frontend/test-client/src/utils/random-casing.test.ts b/frontend/test-client/src/utils/random-casing.test.ts index 67033305..33217525 100644 --- a/frontend/test-client/src/utils/random-casing.test.ts +++ b/frontend/test-client/src/utils/random-casing.test.ts @@ -3,11 +3,11 @@ import assert from "node:assert"; import { randomCasing } from "./random-casing"; describe("randomCasing", () => { - it("simple test", () => { - const input = - "hello, this is a really long string with a lot of characters"; - const result = randomCasing(input); - assert.strictEqual(result.toLowerCase(), input.toLowerCase()); - assert.notStrictEqual(result, input); - }); + it("simple test", () => { + const input = + "hello, this is a really long string with a lot of characters"; + const result = randomCasing(input); + assert.strictEqual(result.toLowerCase(), input.toLowerCase()); + assert.notStrictEqual(result, input); + }); }); diff --git a/frontend/test-client/src/utils/random-casing.ts b/frontend/test-client/src/utils/random-casing.ts index bf9f99dc..ba09dace 100644 --- a/frontend/test-client/src/utils/random-casing.ts +++ b/frontend/test-client/src/utils/random-casing.ts @@ -1,10 +1,10 @@ export function randomCasing(str: string): string { - const chars = str.split(""); - const randomCasedChars = chars.map((char) => { - if (Math.random() < 0.5) { - return char.toUpperCase(); - } - return char.toLowerCase(); - }); - return randomCasedChars.join(""); + const chars = str.split(""); + const randomCasedChars = chars.map((char) => { + if (Math.random() < 0.5) { + return char.toUpperCase(); + } + return char.toLowerCase(); + }); + return randomCasedChars.join(""); } diff --git a/frontend/test-client/src/utils/sleep.ts b/frontend/test-client/src/utils/sleep.ts index 638fc019..ff474799 100644 --- a/frontend/test-client/src/utils/sleep.ts +++ b/frontend/test-client/src/utils/sleep.ts @@ -1,3 +1,3 @@ export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/frontend/test-client/src/utils/with-timeout.ts b/frontend/test-client/src/utils/with-timeout.ts new file mode 100644 index 00000000..71c9568b --- /dev/null +++ b/frontend/test-client/src/utils/with-timeout.ts @@ -0,0 +1,16 @@ +export async function withTimeout( + promise: Promise, + timeoutMs: number, + operationName: string +): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => { + reject( + new Error(`${operationName} timed out after ${timeoutMs}ms`) + ); + }, timeoutMs) + ) + ]); +} diff --git a/frontend/test-client/tsconfig.json b/frontend/test-client/tsconfig.json index 7b38e409..e86df89d 100644 --- a/frontend/test-client/tsconfig.json +++ b/frontend/test-client/tsconfig.json @@ -1,17 +1,17 @@ { - "compilerOptions": { - "baseUrl": ".", - "strict": true, - "target": "ES2022", - "module": "CommonJS", - "esModuleInterop": true, - "lib": [ - "DOM", - "ES2024", - ], - "moduleResolution": "node" - }, - "exclude": [ - "./dist" - ] -} \ No newline at end of file + "compilerOptions": { + "baseUrl": ".", + "strict": true, + "target": "ES2022", + "module": "CommonJS", + "esModuleInterop": true, + "lib": [ + "DOM", + "ES2024", + ], + "moduleResolution": "node" + }, + "exclude": [ + "./dist" + ] +} diff --git a/frontend/test-client/webpack.config.js b/frontend/test-client/webpack.config.js index b2324b9b..6aee1547 100644 --- a/frontend/test-client/webpack.config.js +++ b/frontend/test-client/webpack.config.js @@ -2,29 +2,29 @@ const path = require("path"); const webpack = require("webpack"); module.exports = { - entry: "./src/cli.ts", - target: "node", - mode: "production", - optimization: { - minimize: false - }, - module: { - rules: [ - { - test: /\.ts$/, - use: "ts-loader" - } - ] - }, - resolve: { - extensions: [".ts", ".js"] - }, - output: { - globalObject: "this", - filename: "cli.js", - path: path.resolve(__dirname, "dist") - }, - plugins: [ - new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) - ] + entry: "./src/cli.ts", + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } + ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "cli.js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] }; diff --git a/manifest.json b/manifest.json index 7ad75bea..6f72fab0 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { - "id": "vault-link", - "name": "VaultLink", - "version": "0.9.2", - "minAppVersion": "0.0.0", - "description": "Self-hosted synchronization and collaboration for your Vault.", - "author": "Andras Schmelczer", - "authorUrl": "https://schmelczer.dev", - "isDesktopOnly": false -} \ No newline at end of file + "id": "vault-link", + "name": "VaultLink", + "version": "0.14.0", + "minAppVersion": "0.0.0", + "description": "Self-hosted synchronization and collaboration for your Vault.", + "author": "Andras Schmelczer", + "authorUrl": "https://schmelczer.dev", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a669e690 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "vault-link", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..a9107050 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,11 @@ +# Rustfmt configuration +# This should match the .editorconfig settings + +# Use spaces for indentation (matches .editorconfig indent_style = space) +hard_tabs = false + +# Use 4 spaces for indentation (matches .editorconfig indent_size = 4) +tab_spaces = 4 + +# Use Unix line endings (matches .editorconfig end_of_line = lf) +newline_style = "Unix" diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100755 index 00000000..c87144a9 --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +set -e + +./scripts/utils/check-node.sh + +cd docs + +npm ci +npm run format:check +npm run spell:check +npm run build + +cd - diff --git a/scripts/build-sync-server-binaries.sh b/scripts/build-sync-server-binaries.sh index 80d8d5e2..8d690935 100755 --- a/scripts/build-sync-server-binaries.sh +++ b/scripts/build-sync-server-binaries.sh @@ -16,29 +16,29 @@ rm -f artifacts/sync-server-* for target in $targets; do echo "Building $target..." - + # Set linkers for cross-compilation case "$target" in - aarch64-unknown-linux-gnu) + aarch64-unknown-linux-gnu) export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc ;; - x86_64-unknown-linux-musl) + x86_64-unknown-linux-musl) export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc ;; - x86_64-pc-windows-gnu) + x86_64-pc-windows-gnu) export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc ;; esac - + rustup target add "$target" 2>/dev/null || true - + cargo build --release --target "$target" ext="" [[ "$target" == *windows* ]] && ext=".exe" - + name="sync-server-${target//-/_}$ext" name="${name//x86_64_unknown_linux_gnu/linux-x86_64}" name="${name//x86_64_unknown_linux_musl/linux-x86_64-musl}" name="${name//aarch64_unknown_linux_gnu/linux-aarch64}" name="${name//x86_64_pc_windows_gnu/windows-x86_64}" - + cp "target/$target/release/sync_server$ext" "artifacts/$name" echo "✓ Built $name" done diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 57a78fd6..bea3d982 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -15,6 +15,8 @@ else exit 1 fi +cargo install cargo-edit --force + if [[ -n $(git status --porcelain) ]]; then echo "Your working directory is not clean. Please commit or stash your changes before proceeding." exit 1 @@ -33,6 +35,9 @@ cd .. cp frontend/obsidian-plugin/manifest.json manifest.json # for BRAT, otherwise it wouldn't update +# Format all files across the project (frontend and backend) +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" + # Commit and tag git add . TAG=$(node -p "require('./frontend/obsidian-plugin/package.json').version") diff --git a/scripts/check.sh b/scripts/check.sh index 0a28653c..2ee0dd62 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -2,15 +2,21 @@ set -e -# Parse arguments FIX_MODE=false if [[ "$1" == "--fix" ]]; then FIX_MODE=true echo "Running in fix mode - will automatically fix linting and formatting issues" fi +./scripts/utils/check-node.sh + echo "Running checks in sync-server" + cd sync-server +which sqlx || cargo install sqlx-cli +sqlx database create --database-url sqlite://db.sqlite3 +sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 + cargo test --verbose if [[ "$FIX_MODE" == true ]]; then @@ -21,14 +27,32 @@ else cargo fmt --all -- --check fi -cargo machete +which cargo-machete || cargo install cargo-machete +cargo machete --with-metadata + +cd .. +scripts/update-api-types.sh # this will dirty up the git state if not up-to-date echo "Running checks in frontend" -cd ../frontend -npm ci +cd frontend + +if [[ "$FIX_MODE" == true ]]; then + npm install +else + npm ci +fi + +cd .. + +cd frontend npm run build npm run test npm run lint +cd .. + +# Format all files across the project (frontend and backend) +# Prettier respects .gitignore by default +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then git status --porcelain @@ -36,11 +60,4 @@ if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then exit 1 fi - -cd .. - -if [[ "$FIX_MODE" == true ]]; then - $0 -fi - echo "Success" diff --git a/scripts/clean-up.sh b/scripts/clean-up.sh index 4dfbf4a0..dcf400bb 100755 --- a/scripts/clean-up.sh +++ b/scripts/clean-up.sh @@ -1,4 +1,4 @@ #!/bin/bash -rm -rf sync-server/databases +rm -rf /host/tmp/vaultlink-e2e-databases rm -rf logs diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 952e1855..7ab8d90c 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -3,11 +3,10 @@ set -e set -o pipefail -node_version=$(node -v | sed 's/^v\([0-9]*\).*/\1/') -if [ "$node_version" != "22" ]; then - echo "Error: This script requires Node.js version 22, found: $node_version" - exit 1 -fi +NO_COLOR=1 +FORCE_COLOR=0 + +./scripts/utils/check-node.sh # Check if the argument is provided if [ $# -eq 0 ]; then @@ -20,25 +19,51 @@ process_count=$1 mkdir -p logs +# Build and restart the server +echo "Building server..." +cd sync-server +cargo build --release + +# Kill any existing server process +echo "Stopping existing server..." +pkill -f "sync_server" 2>/dev/null || true +sleep 1 + +# Clean databases (uses tmpfs via /dev/shm for zero disk I/O) +echo "Cleaning databases..." +rm -rf /host/tmp/vaultlink-e2e-databases + +# Start the server in the background +echo "Starting server..." +./target/release/sync_server config-e2e.yml & +server_pid=$! +echo "Server started with PID: $server_pid" + +# Ensure server is killed on script exit +cleanup_server() { + if [ -n "$server_pid" ]; then + echo "Stopping server (PID: $server_pid)..." + kill $server_pid 2>/dev/null || true + wait $server_pid 2>/dev/null || true + server_pid="" + fi +} +trap cleanup_server EXIT + +cd .. + cd frontend npm ci npm run build ../scripts/utils/wait-for-server.sh -cd .. -scripts/update-api-types.sh -if [[ $(git status --porcelain) ]]; then - git status --porcelain - echo "Failing CI because the working directory is not clean after generating api types" - exit 1 -fi -cd frontend - pids=() for i in $(seq 1 $process_count); do node test-client/dist/cli.js > "../logs/log_${i}.log" 2>&1 & - pids+=($!) + pid=$! + pids+=($pid) + echo "Started process $i with PID: $pid (log: logs/log_${i}.log)" done cd .. @@ -52,6 +77,7 @@ print_failed_log() { # Only consider non-zero exit codes as failures if [ $exit_code -ne 0 ]; then + echo "----- Log for process ${pids[$i-1]} (log_${i}.log) -----" cat "$(pwd)/logs/log_${i}.log" echo "Process ${pids[$i-1]} failed with exit code $exit_code. Log file: $(pwd)/logs/log_${i}.log" return 0 @@ -65,10 +91,25 @@ print_failed_log() { return 1 } -echo "Monitoring $process_count processes" +E2E_TIMEOUT=${2:-3600} +start_time=$(date +%s) +echo "Monitoring $process_count processes (timeout: ${E2E_TIMEOUT}s)" # Monitor processes while true; do + # Script-level timeout to prevent indefinite hangs + current_time=$(date +%s) + elapsed=$((current_time - start_time)) + if [ $elapsed -ge $E2E_TIMEOUT ]; then + echo "E2E timeout reached (${E2E_TIMEOUT}s). Killing remaining processes." + for pid in "${pids[@]}"; do + if [ -n "$pid" ]; then + kill $pid 2>/dev/null || true + fi + done + exit 1 + fi + if print_failed_log; then # Kill remaining processes for pid in "${pids[@]}"; do @@ -89,10 +130,10 @@ while true; do done if $all_done; then + cleanup_server echo "All processes completed successfully" exit 0 fi sleep 0.2 done - diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 5aa05d99..3f4a9e2a 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -8,8 +8,15 @@ cd sync-server cargo test export_bindings cd - +# Both target directories contain only generated bindings — wipe and copy +rm -f frontend/sync-client/src/services/types/*.ts +rm -f frontend/history-ui/src/lib/types/*.ts cp -r sync-server/bindings/* frontend/sync-client/src/services/types/ +cp -r sync-server/bindings/* frontend/history-ui/src/lib/types/ cd frontend -npm run lint || npx prettier --write sync-client/src/services/types/*.ts -cd - +npm run lint +cd .. + +# Format all files across the project (frontend and backend) +npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}" diff --git a/scripts/utils/check-node.sh b/scripts/utils/check-node.sh new file mode 100755 index 00000000..d93f2f27 --- /dev/null +++ b/scripts/utils/check-node.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +TARGET_NODE_VERSION=25 + +node_version=$(node -v | sed 's/^v\([0-9]*\).*/\1/') +if [ "$node_version" != "$TARGET_NODE_VERSION" ]; then + echo "Error: This script requires Node.js version $TARGET_NODE_VERSION, found: $node_version" + exit 1 +fi diff --git a/scripts/utils/wait-for-server.sh b/scripts/utils/wait-for-server.sh index 7824c405..71103477 100755 --- a/scripts/utils/wait-for-server.sh +++ b/scripts/utils/wait-for-server.sh @@ -2,14 +2,14 @@ set -e -SERVER_URL="http://localhost:3000" +SERVER_URL="http://localhost:3010" MAX_RETRIES=30 RETRY_INTERVAL_IN_SECONDS=5 echo "Waiting for $SERVER_URL to become available..." count=0 while [ $count -lt $MAX_RETRIES ]; do - if curl -s -f -o /dev/null $SERVER_URL; then + if curl -s -o /dev/null $SERVER_URL; then echo "$SERVER_URL is now available!" break fi diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index d217f1bf..b3da1486 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -99,9 +99,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" dependencies = [ "backtrace", ] @@ -252,7 +252,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2c550fa5c1a07bbc41dbec1dcd4d0e3de230b9072ab8fb70c55d7d37693d66d" dependencies = [ - "darling 0.20.10", + "darling", "heck", "proc-macro-error", "quote", @@ -362,7 +362,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.1", ] [[package]] @@ -375,16 +375,6 @@ dependencies = [ "clap_derive", ] -[[package]] -name = "clap-verbosity-flag" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeab6a5cdfc795a05538422012f20a5496f050223c91be4e5420bfd13c641fb1" -dependencies = [ - "clap", - "log", -] - [[package]] name = "clap_builder" version = "4.5.38" @@ -497,18 +487,8 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core 0.20.10", - "darling_macro 0.20.10", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", + "darling_core", + "darling_macro", ] [[package]] @@ -525,38 +505,13 @@ dependencies = [ "syn 2.0.90", ] -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.90", -] - [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core 0.20.10", - "quote", - "syn 2.0.90", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.90", ] @@ -578,16 +533,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", - "serde", -] - [[package]] name = "digest" version = "0.10.7" @@ -617,12 +562,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.13.0" @@ -856,12 +795,6 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.2" @@ -879,7 +812,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown", ] [[package]] @@ -991,6 +924,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "1.5.1" @@ -1194,17 +1143,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.7.0" @@ -1212,19 +1150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown 0.15.2", - "serde", -] - -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags", - "cfg-if", - "libc", + "hashbrown", ] [[package]] @@ -1305,17 +1231,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -1385,12 +1311,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -1410,12 +1335,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - [[package]] name = "num-integer" version = "0.1.46" @@ -1461,12 +1380,6 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "parking" version = "2.2.1" @@ -1550,12 +1463,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1675,9 +1582,13 @@ dependencies = [ [[package]] name = "reconcile-text" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d690c19b0bf6574cd3591d10f20df5aa52d2af95b8dcaacbc86893292ac8c5" +checksum = "599cf9539996a2a19e501110404c59ba62f4974009f8fb864a8b7151c15ee5a5" +dependencies = [ + "serde", + "thiserror 2.0.17", +] [[package]] name = "redox_syscall" @@ -1688,64 +1599,29 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.90", -] - [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.1.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -1812,30 +1688,6 @@ dependencies = [ "regex", ] -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1907,44 +1759,13 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_with" -version = "3.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6093cd8c01b25262b84927e0f7151692158fab02d961e04c979d3903eba7ecc5" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.7.0", - "schemars 0.9.0", - "schemars 1.0.4", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e6c180db0816026a61afa1cff5344fb7ebded7e4d3062772179f2501481c27" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.90", -] - [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.7.0", + "indexmap", "itoa", "ryu", "serde", @@ -2084,9 +1905,9 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown", "hashlink", - "indexmap 2.7.0", + "indexmap", "log", "memchr", "once_cell", @@ -2095,7 +1916,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -2179,7 +2000,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2218,7 +2039,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2244,7 +2065,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror 2.0.17", "tracing", "url", "uuid", @@ -2302,7 +2123,7 @@ dependencies = [ [[package]] name = "sync_server" -version = "0.9.2" +version = "0.14.0" dependencies = [ "anyhow", "axum", @@ -2312,8 +2133,8 @@ dependencies = [ "bimap", "chrono", "clap", - "clap-verbosity-flag", "futures", + "humantime-serde", "log", "rand 0.9.0", "reconcile-text", @@ -2321,10 +2142,9 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "serde_with", "serde_yaml", "sqlx", - "thiserror 2.0.12", + "thiserror 2.0.17", "tokio", "tower-http", "tracing", @@ -2383,11 +2203,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.17", ] [[package]] @@ -2403,9 +2223,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -2422,37 +2242,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "time" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" - -[[package]] -name = "time-macros" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.7.6" @@ -2480,29 +2269,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -2624,14 +2410,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -2648,7 +2434,7 @@ checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" dependencies = [ "chrono", "lazy_static", - "thiserror 2.0.12", + "thiserror 2.0.17", "ts-rs-macros", "uuid", ] @@ -2876,22 +2662,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.9" @@ -2901,12 +2671,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.52.0" @@ -2922,6 +2686,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -2949,6 +2719,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index 016c6386..fac06efa 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -5,36 +5,35 @@ authors = ["Andras Schmelczer "] edition = "2024" license = "MIT" repository = "https://github.com/schmelczer/vault-link" -version = "0.9.2" +version = "0.14.0" [dependencies] serde = { version = "1.0.219", default-features = false, features = ["derive"] } thiserror = { version = "2.0.12", default-features = false } -tokio = { version = "1.47.1", features = ["full"]} +tokio = { version = "1.48.0", features = ["full"]} uuid = { version = "1.16.0", features = ["v4", "serde"] } -log = { version = "0.4.27" } -anyhow = { version = "1.0.98", features = ["backtrace"] } +log = { version = "0.4.28" } +anyhow = { version = "1.0.100", features = ["backtrace"] } axum = { version = "0.7.4", features = ["ws", "macros", "tracing", "multipart"]} axum-extra = { version = "0.9.6", features = ["typed-header"] } axum_typed_multipart = "0.11.0" tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout"] } tracing = "0.1.41" -tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"]} +tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]} +humantime-serde = "1.1.1" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } chrono = { version = "0.4.41", features = ["serde"] } rand = "0.9.0" sanitize-filename = "0.6.0" -regex = "1.11.1" +regex = "1.12.2" clap = { version = "4.5.38", features = ["derive"] } futures = "0.3.31" serde_yaml = "0.9.34" serde_json = "1.0.140" -clap-verbosity-flag = "3.0.3" bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } -serde_with = "3.15.0" base64 = "0.22.1" -reconcile-text = "0.5.0" +reconcile-text = { version = "0.8.0", features = ["serde"] } [profile.release] codegen-units = 1 @@ -87,3 +86,6 @@ similar_names = { level = "allow", priority = 1 } missing_docs_in_private_items = { level = "allow", priority = 1 } pedantic = { level = "warn", priority = 0 } + +[package.metadata.cargo-machete] +ignored = ["humantime-serde"] # only used in serde macro diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index cfb76138..77599b2c 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.90-slim-trixie AS builder +FROM rust:1.91-slim-trixie AS builder WORKDIR /usr/src/backend diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index 5f2346d6..1f235b01 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -1,13 +1,16 @@ database: databases_directory_path: databases max_connections_per_vault: 12 - cursor_timeout_seconds: 60 + cursor_timeout: 1m server: host: 0.0.0.0 port: 3000 max_body_size_mb: 512 max_clients_per_vault: 256 - response_timeout_seconds: 60 + response_timeout: 30m + mergeable_file_extensions: + - md + - txt users: user_configs: - name: admin @@ -24,3 +27,7 @@ users: type: allow_list allowed: - default +logging: + log_directory: logs + log_rotation: 7days + log_level: info diff --git a/sync-server/rust-toolchain.toml b/sync-server/rust-toolchain.toml index 635d09fb..010956cc 100644 --- a/sync-server/rust-toolchain.toml +++ b/sync-server/rust-toolchain.toml @@ -1,7 +1,7 @@ [toolchain] channel = "1.89.0" -targets = [ - "x86_64-unknown-linux-gnu", +targets = [ + "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "aarch64-unknown-linux-gnu", "x86_64-pc-windows-gnu", diff --git a/sync-server/src/app_state.rs b/sync-server/src/app_state.rs index a61467d5..2019e08e 100644 --- a/sync-server/src/app_state.rs +++ b/sync-server/src/app_state.rs @@ -2,14 +2,12 @@ pub mod cursors; pub mod database; pub mod websocket; -use std::ffi::OsString; - use anyhow::Result; use cursors::Cursors; use database::Database; use websocket::broadcasts::Broadcasts; -use crate::{config::Config, consts::DEFAULT_CONFIG_PATH}; +use crate::config::Config; #[derive(Clone, Debug)] pub struct AppState { @@ -20,11 +18,7 @@ pub struct AppState { } impl AppState { - pub async fn try_new(config_path: Option) -> Result { - let config_path = config_path.unwrap_or_else(|| OsString::from(DEFAULT_CONFIG_PATH)); - let path = std::path::PathBuf::from(config_path); - - let config = Config::read_or_create(&path).await?; + pub async fn try_new(config: Config) -> Result { let broadcasts = Broadcasts::new(&config.server); let database = Database::try_new(&config.database, &broadcasts).await?; let cursors: Cursors = Cursors::new(&config.database, &broadcasts); diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 346fea38..75ce6df4 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -2,14 +2,16 @@ use core::time::Duration; use std::{collections::HashMap, sync::Arc}; use anyhow::{Context as _, Result}; +use log::info; use models::{ DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId, }; -use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc}; +use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc}; pub mod models; use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; use tokio::sync::Mutex; +use tokio::time::Instant; use uuid::fmt::Hyphenated; use super::websocket::{ @@ -18,11 +20,26 @@ use super::websocket::{ }; use crate::config::database_config::DatabaseConfig; +#[derive(Clone)] +struct PoolWithTimestamp { + pool: Pool, + last_accessed: Instant, +} + +impl std::fmt::Debug for PoolWithTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PoolWithTimestamp") + .field("pool", &"Pool") + .field("last_accessed", &self.last_accessed) + .finish() + } +} + #[derive(Clone, Debug)] pub struct Database { config: DatabaseConfig, broadcasts: Broadcasts, - connection_pools: Arc>>>, + connection_pools: Arc>>, } pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; @@ -33,13 +50,14 @@ impl Database { .await .with_context(|| { format!( - "Failed to create databases directory: {}", + "Failed to create databases directory at `{}`", config.databases_directory_path.to_string_lossy() ) })?; let mut connection_pools = std::collections::HashMap::new(); + info!("Applying pending database migrations"); let mut entries = tokio::fs::read_dir(&config.databases_directory_path).await?; while let Some(entry) = entries.next_entry().await? { if !entry.file_name().to_string_lossy().ends_with(".sqlite") { @@ -52,17 +70,26 @@ impl Database { .trim_end_matches(".sqlite") .to_owned(); + let pool = Self::create_vault_database(config, &vault).await?; connection_pools.insert( vault.clone(), - Self::create_vault_database(config, &vault).await?, + PoolWithTimestamp { + pool, + last_accessed: Instant::now(), + }, ); } - Ok(Self { + let database = Self { config: config.clone(), connection_pools: Arc::new(Mutex::new(connection_pools)), broadcasts: broadcasts.clone(), - }) + }; + + // Start background task to cleanup idle connection pools + database.start_idle_pool_cleanup(); + + Ok(database) } async fn create_vault_database( @@ -76,15 +103,18 @@ impl Database { let connection_options = SqliteConnectOptions::new() .filename(file_name.clone()) .create_if_missing(true) + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full) .busy_timeout(Duration::from_secs(3600)) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) + .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)); let pool = SqlitePoolOptions::new() .max_connections(config.max_connections_per_vault) + .acquire_slow_threshold(Duration::from_secs(30)) .test_before_acquire(true) .connect_with(connection_options) .await - .with_context(|| format!("Cannot open database at {}", file_name.display()))?; + .with_context(|| format!("Cannot open database at `{}`", file_name.display()))?; Self::run_migrations(&pool).await?; @@ -100,16 +130,26 @@ impl Database { async fn get_connection_pool(&self, vault: &VaultId) -> Result> { let mut pools = self.connection_pools.lock().await; + if !pools.contains_key(vault) { let pool = Self::create_vault_database(&self.config, vault).await?; - pools.insert(vault.clone(), pool); + pools.insert( + vault.clone(), + PoolWithTimestamp { + pool, + last_accessed: Instant::now(), + }, + ); } - let pool = pools - .get(vault) + let pool_with_timestamp = pools + .get_mut(vault) .expect("Pool was just inserted or already exists"); - Ok(pool.clone()) + // Update last accessed time + pool_with_timestamp.last_accessed = Instant::now(); + + Ok(pool_with_timestamp.pool.clone()) } /// Attempting to write from this transaction might result in a @@ -218,7 +258,7 @@ impl Database { .await } .with_context(|| { - format!("Cannot fetch latest documents since vault_update_id {vault_update_id}") + format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`") }) .map(|rows| { rows.into_iter() @@ -281,10 +321,10 @@ impl Database { device_id, has_been_merged from latest_document_versions - where relative_path = ? + where relative_path = ? and is_deleted = false order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however, - -- multiple documents can have the same `relative_path`, if they have been deleted. That's - -- why we only care about the latest version of the document with the given relative path. + -- multiple documents can have the same `relative_path`, if they have been deleted. That's + -- why we only care about the latest version of the document with the given relative path. limit 1 "#, relative_path @@ -434,4 +474,42 @@ impl Database { Ok(()) } + + /// Cleanup idle connection pools that haven't been accessed in more than 5 minutes + async fn cleanup_idle_pools(&self) { + let mut pools = self.connection_pools.lock().await; + let now = Instant::now(); + let idle_timeout = Duration::from_secs(5 * 60); // 5 minutes + + // Collect vaults to remove + let vaults_to_remove: Vec = pools + .iter() + .filter(|(_, pool_with_timestamp)| { + now.duration_since(pool_with_timestamp.last_accessed) > idle_timeout + }) + .map(|(vault_id, _)| vault_id.clone()) + .collect(); + + // Close and remove idle pools + for vault_id in &vaults_to_remove { + if let Some(pool_with_timestamp) = pools.remove(vault_id) { + info!("Closing idle database connection pool for vault `{vault_id}`"); + pool_with_timestamp.pool.close().await; + } + } + } + + /// Start a background task that periodically cleans up idle connection pools + fn start_idle_pool_cleanup(&self) { + let database = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every minute + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + database.cleanup_idle_pools().await; + } + }); + } } diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs index cef6ee6a..60ae0219 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use anyhow::Context; +use log::{debug, warn}; use tokio::sync::{Mutex, broadcast}; use super::models::WebSocketServerMessageWithOrigin; @@ -32,13 +33,18 @@ impl Broadcasts { } /// Notify all clients (who are subscribed to the vault) about an update. - /// We only log failures. + /// We only log failures and don't propagate them. pub async fn send_document_update( &self, vault: VaultId, document: WebSocketServerMessageWithOrigin, ) { - let tx = self.get_or_create(vault).await; + let tx = self.get_or_create(vault.clone()).await; + + if tx.receiver_count() == 0 { + debug!("Skipping broadcast, no clients connected for vault `{vault}`"); + return; + } let result = tx .send(document) @@ -46,7 +52,7 @@ impl Broadcasts { .map_err(server_error); if result.is_err() { - log::debug!("Failed to send message: {result:?}"); + warn!("Failed to send message: {result:?}"); } } diff --git a/sync-server/src/cli/args.rs b/sync-server/src/cli/args.rs index 603d8d15..15273ee1 100644 --- a/sync-server/src/cli/args.rs +++ b/sync-server/src/cli/args.rs @@ -1,7 +1,6 @@ use std::ffi::OsString; use clap::Parser; -use clap_verbosity_flag::{InfoLevel, Verbosity}; use crate::cli::color_when::ColorWhen; @@ -12,9 +11,6 @@ pub struct Args { #[arg(index = 1)] pub config_path: Option, - #[command(flatten)] - pub verbose: Verbosity, - #[arg( long, value_name = "WHEN", diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs index 700b1ea8..6a003d2e 100644 --- a/sync-server/src/config.rs +++ b/sync-server/src/config.rs @@ -3,12 +3,14 @@ use std::path::Path; use anyhow::{Context as _, Result}; use database_config::DatabaseConfig; use log::info; +use logging_config::LoggingConfig; use serde::{Deserialize, Serialize}; use server_config::ServerConfig; use tokio::fs; use user_config::UserConfig; pub mod database_config; +pub mod logging_config; pub mod server_config; pub mod user_config; @@ -20,13 +22,15 @@ pub struct Config { pub server: ServerConfig, #[serde(default)] pub users: UserConfig, + #[serde(default)] + pub logging: LoggingConfig, } impl Config { pub async fn read_or_create(path: &Path) -> Result { let config = if path.exists() { info!( - "Loading configuration from '{}'", + "Loading configuration from `{}`", path.canonicalize().unwrap().display() ); Self::load_from_file(path).await? @@ -36,7 +40,7 @@ impl Config { config.write(path).await?; info!( - "Updated configuration at '{}'", + "Updated configuration at `{}`", path.canonicalize().unwrap().display() ); @@ -46,14 +50,12 @@ impl Config { pub async fn load_from_file(path: &Path) -> Result { let contents = fs::read_to_string(path).await.with_context(|| { format!( - "Cannot load configuration from disk from {}", + "Cannot load configuration from disk from `{}`", path.display() ) })?; - let config = serde_yaml::from_str(&contents).context("Failed to parse configuration")?; - - Ok(config) + serde_yaml::from_str(&contents).context("Failed to parse configuration") } pub async fn write(&self, path: &Path) -> Result<()> { diff --git a/sync-server/src/config/database_config.rs b/sync-server/src/config/database_config.rs index f1c92d9d..20a9a21e 100644 --- a/sync-server/src/config/database_config.rs +++ b/sync-server/src/config/database_config.rs @@ -2,13 +2,11 @@ use std::{path::PathBuf, time::Duration}; use log::debug; use serde::{Deserialize, Serialize}; -use serde_with::serde_as; use crate::consts::{ DEFAULT_CURSOR_TIMEOUT, DEFAULT_DATABASES_DIRECTORY_PATH, DEFAULT_MAX_CONNECTIONS_PER_VAULT, }; -#[serde_with::serde_as] #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DatabaseConfig { #[serde(default = "default_databases_directory_path")] @@ -17,8 +15,7 @@ pub struct DatabaseConfig { #[serde(default = "default_max_connections_per_vault")] pub max_connections_per_vault: u32, - #[serde(default = "default_cursor_timeout", rename = "cursor_timeout_seconds")] - #[serde_as(as = "serde_with::DurationSeconds")] + #[serde(default = "default_cursor_timeout", with = "humantime_serde")] pub cursor_timeout: Duration, } diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs new file mode 100644 index 00000000..ad449d1a --- /dev/null +++ b/sync-server/src/config/logging_config.rs @@ -0,0 +1,46 @@ +use std::time::Duration; + +use log::debug; +use serde::{Deserialize, Serialize}; + +use crate::{ + consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL}, + utils::log_level::LogLevel, +}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LoggingConfig { + #[serde(default = "default_log_directory")] + pub log_directory: String, + + #[serde(default = "default_log_rotation", with = "humantime_serde")] + pub log_rotation: Duration, + + #[serde(default = "default_log_level")] + pub log_level: LogLevel, +} + +impl Default for LoggingConfig { + fn default() -> Self { + Self { + log_directory: default_log_directory(), + log_rotation: default_log_rotation(), + log_level: default_log_level(), + } + } +} + +fn default_log_directory() -> String { + debug!("Using default log directory: `{DEFAULT_LOG_DIRECTORY}`"); + DEFAULT_LOG_DIRECTORY.to_owned() +} + +fn default_log_rotation() -> Duration { + debug!("Using default log rotation: {DEFAULT_LOG_ROTATION_INTERVAL:?}"); + DEFAULT_LOG_ROTATION_INTERVAL +} + +fn default_log_level() -> LogLevel { + debug!("Using default log level: Info"); + DEFAULT_LOG_LEVEL +} diff --git a/sync-server/src/config/server_config.rs b/sync-server/src/config/server_config.rs index ce922fb9..4a9da0f4 100644 --- a/sync-server/src/config/server_config.rs +++ b/sync-server/src/config/server_config.rs @@ -1,9 +1,10 @@ use log::debug; use serde::{Deserialize, Serialize}; +use std::time::Duration; use crate::consts::{ - DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_PORT, - DEFAULT_RESPONSE_TIMEOUT_SECONDS, + DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, + DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS, }; #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -20,8 +21,11 @@ pub struct ServerConfig { #[serde(default = "default_max_clients_per_vault")] pub max_clients_per_vault: usize, - #[serde(default = "default_response_timeout_seconds")] - pub response_timeout_seconds: u64, + #[serde(default = "default_response_timeout", with = "humantime_serde")] + pub response_timeout: Duration, + + #[serde(default = "default_mergeable_file_extensions")] + pub mergeable_file_extensions: Vec, } fn default_host() -> String { @@ -35,7 +39,7 @@ fn default_port() -> u16 { } fn default_max_body_size_mb() -> usize { - debug!("Using default max body size (MB): {DEFAULT_MAX_BODY_SIZE_MB}"); + debug!("Using default max body size {DEFAULT_MAX_BODY_SIZE_MB} MB"); DEFAULT_MAX_BODY_SIZE_MB } @@ -44,7 +48,15 @@ fn default_max_clients_per_vault() -> usize { DEFAULT_MAX_CLIENTS_PER_VAULT } -fn default_response_timeout_seconds() -> u64 { - debug!("Using default response timeout (seconds): {DEFAULT_RESPONSE_TIMEOUT_SECONDS}"); +fn default_response_timeout() -> Duration { + debug!("Using default response timeout: {DEFAULT_RESPONSE_TIMEOUT_SECONDS:?}"); DEFAULT_RESPONSE_TIMEOUT_SECONDS } + +fn default_mergeable_file_extensions() -> Vec { + debug!("Using default mergeable file extensions: {DEFAULT_MERGEABLE_FILE_EXTENSIONS:?}"); + DEFAULT_MERGEABLE_FILE_EXTENSIONS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} diff --git a/sync-server/src/config/user_config.rs b/sync-server/src/config/user_config.rs index ed7ecc23..8b2537f0 100644 --- a/sync-server/src/config/user_config.rs +++ b/sync-server/src/config/user_config.rs @@ -20,15 +20,15 @@ where for user in &users { if let Some(existing_name) = user_token_map.get_by_right(&user.token) { return Err(D::Error::custom(format!( - "Duplicate user token found: '{}' for users '{}' and '{}'. User tokens must be \ - unique.", + "Duplicate user token found: `{}` for users `{}` and `{}`. User tokens must be \ + unique.", user.token, existing_name, user.name ))); } if user_token_map.contains_left(&user.name) { return Err(D::Error::custom(format!( - "Duplicate user name found: '{}'. User names must be unique.", + "Duplicate user name found: `{}`. User names must be unique.", user.name ))); } diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs index df5a2844..98ed1c1f 100644 --- a/sync-server/src/consts.rs +++ b/sync-server/src/consts.rs @@ -1,5 +1,7 @@ use std::time::Duration; +use crate::utils::log_level::LogLevel; + pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; @@ -9,5 +11,13 @@ pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; -pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: u64 = 60; +pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: Duration = Duration::from_secs(1800); pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256; + +pub const DEFAULT_LOG_DIRECTORY: &str = "logs"; +pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day +pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info; + +pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; + +pub const SUPPORTED_API_VERSION: u32 = 2; diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs index 987c3011..831b0e86 100644 --- a/sync-server/src/errors.rs +++ b/sync-server/src/errors.rs @@ -55,6 +55,7 @@ pub struct SerializedError { impl Display for SerializedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}: {}", self.error_type, self.message)?; if !self.causes.is_empty() { write!(f, "\nCauses:\n")?; for cause in &self.causes { diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs index 83556542..1285ed7b 100644 --- a/sync-server/src/main.rs +++ b/sync-server/src/main.rs @@ -6,24 +6,45 @@ mod errors; mod server; mod utils; -use std::process::ExitCode; +use std::{ffi::OsString, path::PathBuf, process::ExitCode}; use anyhow::{Context as _, Result}; use clap::Parser; use cli::args::Args; +use config::Config; +use consts::DEFAULT_CONFIG_PATH; use errors::{SyncServerError, init_error}; use log::info; use server::create_server; -use tracing_subscriber::{EnvFilter, fmt::format, util::SubscriberInitExt}; +use tracing_subscriber::{EnvFilter, fmt::format, layer::SubscriberExt, util::SubscriberInitExt}; +use utils::rotating_file_writer::RotatingFileWriter; #[tokio::main] async fn main() -> ExitCode { let args = Args::parse(); - let mut result = set_up_logging(&args); + let config_path = args + .config_path + .clone() + .unwrap_or_else(|| OsString::from(DEFAULT_CONFIG_PATH)); + let path = PathBuf::from(config_path); + + let config = match Config::read_or_create(&path) + .await + .context("Failed to start server") + .map_err(init_error) + { + Ok(config) => config, + Err(e) => { + eprintln!("{}", e.serialize()); + return ExitCode::FAILURE; + } + }; + + let mut result = set_up_logging(&args, &config.logging); if result.is_ok() { - result = start_server(args).await; + result = start_server(config).await; } match result { @@ -35,15 +56,11 @@ async fn main() -> ExitCode { } } -fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { - let level_filter = match args.verbose.log_level_filter() { - // We don't want to allow disabling all logging - log::LevelFilter::Off | log::LevelFilter::Error => tracing::Level::ERROR, - log::LevelFilter::Warn => tracing::Level::WARN, - log::LevelFilter::Info => tracing::Level::INFO, - log::LevelFilter::Debug => tracing::Level::DEBUG, - log::LevelFilter::Trace => tracing::Level::TRACE, - }; +fn set_up_logging( + args: &Args, + logging_config: &config::logging_config::LoggingConfig, +) -> Result<(), SyncServerError> { + let level_filter = logging_config.log_level.as_tracing_level(); let env_filter = EnvFilter::builder() .with_default_directive(level_filter.into()) @@ -53,19 +70,35 @@ fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { let use_colors = args.color.use_colors(); - let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug; + let is_debug_mode = logging_config.log_level.is_debug_or_trace(); - tracing_subscriber::fmt() + let file_appender = RotatingFileWriter::new( + &logging_config.log_directory, + "vault-link", + logging_config.log_rotation, + ) + .context("Failed to create rotating file writer") + .map_err(init_error)?; + + let format = format() + .with_target(is_debug_mode) + .with_line_number(is_debug_mode) + .compact(); + + let stderr_layer = tracing_subscriber::fmt::layer() .with_ansi(use_colors) - .with_env_filter(env_filter) - .event_format( - format() - .without_time() - .with_target(is_debug_mode) - .with_line_number(is_debug_mode) - .compact(), - ) - .finish() + .with_writer(std::io::stderr) + .event_format(format.clone()); + + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file_appender) + .event_format(format); + + tracing_subscriber::registry() + .with(env_filter) + .with(file_layer) + .with(stderr_layer) .try_init() .context("Failed to initialise tracing") .map_err(init_error)?; @@ -73,13 +106,13 @@ fn set_up_logging(args: &Args) -> Result<(), SyncServerError> { Ok(()) } -async fn start_server(args: Args) -> Result<(), SyncServerError> { +async fn start_server(config: Config) -> Result<(), SyncServerError> { info!( "Starting VaultLink server version {}", env!("CARGO_PKG_VERSION") ); - create_server(args.config_path) + create_server(config) .await .context("Failed to start server") .map_err(init_error) diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index cddcc1b5..01b09cf6 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -13,8 +13,6 @@ mod responses; mod update_document; mod websocket; -use std::{ffi::OsString, time::Duration}; - use anyhow::{Context as _, Result, anyhow}; use auth::auth_middleware; use axum::{ @@ -42,12 +40,12 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, - config::server_config::ServerConfig, + config::{Config, server_config::ServerConfig}, errors::{client_error, not_found_error}, }; -pub async fn create_server(config_path: Option) -> Result<()> { - let app_state = AppState::try_new(config_path) +pub async fn create_server(config: Config) -> Result<()> { + let app_state = AppState::try_new(config) .await .context("Failed to initialise app state")?; @@ -62,9 +60,7 @@ pub async fn create_server(config_path: Option) -> Result<()> { .layer(RequestBodyLimitLayer::new( app_state.config.server.max_body_size_mb * 1024 * 1024, )) - .layer(TimeoutLayer::new(Duration::from_secs( - server_config.response_timeout_seconds, - ))) + .layer(TimeoutLayer::new(server_config.response_timeout)) .layer( CorsLayer::new() .allow_origin("*".parse::().expect("Failed to parse origin")) @@ -117,16 +113,20 @@ fn get_authed_routes(app_state: AppState) -> Router { get(fetch_latest_document_version::fetch_latest_document_version), ) .route( - "/vaults/:vault_id/documents/:document_id", - put(update_document::update_document), + "/vaults/:vault_id/documents/:document_id/binary", + put(update_document::update_binary), ) .route( - "/vaults/:vault_id/documents/:document_id/versions/:version_id", - put(fetch_document_version::fetch_document_version), + "/vaults/:vault_id/documents/:document_id/text", + put(update_document::update_text), ) .route( - "/vaults/:vault_id/documents/:document_id/versions/:version_id/content", - put(fetch_document_version_content::fetch_document_version_content), + "/vaults/:vault_id/documents/:document_id/versions/:vault_update_id", + get(fetch_document_version::fetch_document_version), + ) + .route( + "/vaults/:vault_id/documents/:document_id/versions/:vault_update_id/content", + get(fetch_document_version_content::fetch_document_version_content), ) .route( "/vaults/:vault_id/documents/:document_id", diff --git a/sync-server/src/server/auth.rs b/sync-server/src/server/auth.rs index d27c16e3..e56f4acc 100644 --- a/sync-server/src/server/auth.rs +++ b/sync-server/src/server/auth.rs @@ -52,14 +52,14 @@ pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result allowed.contains(vault_id), } { info!( - "User '{}' is authenticated and is authorised to access to vault '{vault_id}'", + "User `{}` is authenticated and is authorised to access to vault `{vault_id}`", user.name ); Ok(user) } else { info!( - "User '{}' is authenticated but is not authorised to access vault '{vault_id}'", + "User `{}` is authenticated but is not authorised to access vault `{vault_id}`", user.name ); diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 0f698538..859c0db4 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -4,6 +4,7 @@ use axum::{ }; use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; +use log::{debug, info}; use serde::Deserialize; use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; @@ -14,7 +15,10 @@ use crate::{ }, config::user_config::User, errors::{SyncServerError, client_error, server_error}, - utils::{normalize::normalize, sanitize_path::sanitize_path}, + utils::{ + find_first_available_path::find_first_available_path, normalize::normalize, + sanitize_path::sanitize_path, + }, }; #[derive(Deserialize)] @@ -34,6 +38,8 @@ pub async fn create_document( State(state): State, TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { + debug!("Creating document in vault `{vault_id}`"); + let mut transaction = state .database .create_write_transaction(&vault_id) @@ -50,7 +56,7 @@ pub async fn create_document( if existing_version.is_some() { return Err(client_error(anyhow::anyhow!( - "Document with the same ID already exists" + "Document with the same ID `{document_id}` already exists" ))); } @@ -66,11 +72,25 @@ pub async fn create_document( .map_err(server_error)?; let sanitized_relative_path = sanitize_path(&request.relative_path); + let deduped_path = find_first_available_path( + &vault_id, + &sanitized_relative_path, + &state.database, + &mut transaction, + ) + .await + .map_err(server_error)?; + + if deduped_path != sanitized_relative_path { + info!( + "Document already exists at new location: `{sanitized_relative_path}` when trying to create it in vault `{vault_id}`, deconflicting by creating at `{deduped_path}`" + ); + } let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, document_id, - relative_path: sanitized_relative_path, + relative_path: deduped_path, content: request.content.contents.to_vec(), updated_date: chrono::Utc::now(), is_deleted: false, diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index f7080417..e126d6b5 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,8 +1,10 @@ +use anyhow::Context; use axum::{ Extension, Json, extract::{Path, State}, }; use axum_extra::TypedHeader; +use log::{debug, info}; use serde::Deserialize; use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; @@ -37,6 +39,8 @@ pub async fn delete_document( State(state): State, Json(request): Json, ) -> Result, SyncServerError> { + debug!("Deleting document `{document_id}` in vault `{vault_id}`"); + let mut transaction = state .database .create_write_transaction(&vault_id) @@ -49,12 +53,26 @@ pub async fn delete_document( .await .map_err(server_error)?; - let latest_content = state + let latest_version = state .database .get_latest_document(&vault_id, &document_id, Some(&mut transaction)) .await - .map_err(server_error)? - .map_or_else(Vec::new, |version| version.content); // in case the document has never existed before deleting it + .map_err(server_error)?; + + if let Some(latest_version) = &latest_version + && latest_version.is_deleted + { + transaction + .rollback() + .await + .context("Failed to roll back transaction") + .map_err(server_error)?; + + info!("Document `{document_id}` has already been deleted",); + return Ok(Json(latest_version.clone().into())); + } + + let latest_content = latest_version.map_or_else(Vec::new, |version| version.content); // in case the document has never existed before deleting it let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, diff --git a/sync-server/src/server/fetch_document_version.rs b/sync-server/src/server/fetch_document_version.rs index 5b571a7b..c30f1d76 100644 --- a/sync-server/src/server/fetch_document_version.rs +++ b/sync-server/src/server/fetch_document_version.rs @@ -3,6 +3,7 @@ use axum::{ Json, extract::{Path, State}, }; +use log::debug; use serde::Deserialize; use crate::{ @@ -32,6 +33,10 @@ pub async fn fetch_document_version( }): Path, State(state): State, ) -> Result, SyncServerError> { + debug!( + "Fetching document version `{vault_update_id}` for document `{document_id}` in vault `{vault_id}`" + ); + let result = state .database .get_document_version(&vault_id, vault_update_id, None) @@ -49,7 +54,7 @@ pub async fn fetch_document_version( if result.document_id != document_id { return Err(not_found_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ - `{vault_update_id}`", + `{vault_update_id}`", ))); } diff --git a/sync-server/src/server/fetch_document_version_content.rs b/sync-server/src/server/fetch_document_version_content.rs index a419b7bf..9fdd0ad8 100644 --- a/sync-server/src/server/fetch_document_version_content.rs +++ b/sync-server/src/server/fetch_document_version_content.rs @@ -3,6 +3,7 @@ use axum::{ body::Bytes, extract::{Path, State}, }; +use log::debug; use serde::Deserialize; use crate::{ @@ -32,6 +33,10 @@ pub async fn fetch_document_version_content( }): Path, State(state): State, ) -> Result { + debug!( + "Fetching document version `{vault_update_id}` for document `{document_id}` in vault `{vault_id}`" + ); + let result = state .database .get_document_version(&vault_id, vault_update_id, None) @@ -49,7 +54,7 @@ pub async fn fetch_document_version_content( if result.document_id != document_id { return Err(not_found_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ - `{vault_update_id}`", + `{vault_update_id}`", ))); } diff --git a/sync-server/src/server/fetch_latest_document_version.rs b/sync-server/src/server/fetch_latest_document_version.rs index 07f07860..a9973606 100644 --- a/sync-server/src/server/fetch_latest_document_version.rs +++ b/sync-server/src/server/fetch_latest_document_version.rs @@ -3,6 +3,7 @@ use axum::{ Json, extract::{Path, State}, }; +use log::debug; use serde::Deserialize; use crate::{ @@ -30,6 +31,8 @@ pub async fn fetch_latest_document_version( }): Path, State(state): State, ) -> Result, SyncServerError> { + debug!("Fetching latest document version for document `{document_id}` in vault `{vault_id}`"); + let latest_version = state .database .get_latest_document(&vault_id, &document_id, None) diff --git a/sync-server/src/server/fetch_latest_documents.rs b/sync-server/src/server/fetch_latest_documents.rs index 6101f55c..209374ce 100644 --- a/sync-server/src/server/fetch_latest_documents.rs +++ b/sync-server/src/server/fetch_latest_documents.rs @@ -2,6 +2,7 @@ use axum::{ Json, extract::{Path, Query, State}, }; +use log::debug; use serde::Deserialize; use super::responses::FetchLatestDocumentsResponse; @@ -31,6 +32,8 @@ pub async fn fetch_latest_documents( Query(QueryParams { since_update_id }): Query, State(state): State, ) -> Result, SyncServerError> { + debug!("Fetching latest documents in vault `{vault_id}` since update ID `{since_update_id:?}`"); + let documents = if let Some(since_update_id) = since_update_id { state .database diff --git a/sync-server/src/server/ping.rs b/sync-server/src/server/ping.rs index 620ef0d4..31aa8acd 100644 --- a/sync-server/src/server/ping.rs +++ b/sync-server/src/server/ping.rs @@ -6,11 +6,13 @@ use axum_extra::{ TypedHeader, headers::{Authorization, authorization::Bearer}, }; +use log::debug; use serde::Deserialize; use super::{auth::auth, responses::PingResponse}; use crate::{ app_state::{AppState, database::models::VaultId}, + consts::SUPPORTED_API_VERSION, errors::SyncServerError, utils::normalize::normalize, }; @@ -27,11 +29,15 @@ pub async fn ping( Path(PingPathParams { vault_id }): Path, State(state): State, ) -> Result, SyncServerError> { + debug!("Pinging vault `{vault_id}`"); + let is_authenticated = maybe_auth_header .is_some_and(|auth_header| auth(&state, auth_header.token(), &vault_id).is_ok()); Ok(Json(PingResponse { server_version: env!("CARGO_PKG_VERSION").to_owned(), is_authenticated, + mergeable_file_extensions: state.config.server.mergeable_file_extensions.clone(), + supported_api_version: SUPPORTED_API_VERSION, })) } diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 9d1e478b..119ad467 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -1,5 +1,6 @@ use axum::body::Bytes; use axum_typed_multipart::{FieldData, TryFromMultipart}; +use reconcile_text::NumberOrText; use serde::{self, Deserialize}; use ts_rs::TS; @@ -20,17 +21,28 @@ pub struct CreateDocumentVersion { pub content: FieldData, } -#[derive(TS, Debug, TryFromMultipart)] -#[ts(export)] -pub struct UpdateDocumentVersion { +#[derive(Debug, TryFromMultipart)] +pub struct UpdateBinaryDocumentVersion { pub parent_version_id: VaultUpdateId, pub relative_path: String, - #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, } +#[derive(TS, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct UpdateTextDocumentVersion { + #[ts(as = "i32")] + pub parent_version_id: VaultUpdateId, + + pub relative_path: String, + + #[ts(type = "Array")] + pub content: Vec, +} + #[derive(TS, Debug, Deserialize)] #[serde(rename_all = "camelCase")] #[ts(export)] diff --git a/sync-server/src/server/responses.rs b/sync-server/src/server/responses.rs index 5cfaa5d5..a8b3fcd7 100644 --- a/sync-server/src/server/responses.rs +++ b/sync-server/src/server/responses.rs @@ -16,6 +16,13 @@ pub struct PingResponse { /// Whether the client is authenticated based on the sent Authorization /// header. pub is_authenticated: bool, + + /// List of file extensions that are allowed to be merged. + pub mergeable_file_extensions: Vec, + + /// API version ensuring backwards & forwards compatibility between the client + /// and server. + pub supported_api_version: u32, } /// Response to a fetch latest documents request. diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index bf11504c..00fbd008 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -5,24 +5,26 @@ use axum::{ }; use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; -use log::info; -use reconcile_text::{BuiltinTokenizer, is_binary, reconcile}; +use log::{debug, info}; +use reconcile_text::{BuiltinTokenizer, EditedText, reconcile}; use serde::Deserialize; use super::{ - device_id_header::DeviceIdHeader, requests::UpdateDocumentVersion, + device_id_header::DeviceIdHeader, requests::UpdateTextDocumentVersion, responses::DocumentUpdateResponse, }; use crate::{ app_state::{ AppState, - database::models::{DocumentId, StoredDocumentVersion, VaultId}, + database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, }, config::user_config::User, - errors::{SyncServerError, not_found_error, server_error}, + errors::{SyncServerError, client_error, not_found_error, server_error}, + server::requests::UpdateBinaryDocumentVersion, utils::{ - dedup_paths::dedup_paths, is_file_type_mergable::is_file_type_mergable, - normalize::normalize, sanitize_path::sanitize_path, + find_first_available_path::find_first_available_path, is_binary::is_binary, + is_file_type_mergable::is_file_type_mergable, normalize::normalize, + sanitize_path::sanitize_path, }, }; @@ -30,13 +32,11 @@ use crate::{ pub struct UpdateDocumentPathParams { #[serde(deserialize_with = "normalize")] vault_id: VaultId, - document_id: DocumentId, } #[axum::debug_handler] -#[allow(clippy::too_many_lines)] -pub async fn update_document( +pub async fn update_binary( Path(UpdateDocumentPathParams { vault_id, document_id, @@ -44,25 +44,96 @@ pub async fn update_document( Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, - TypedMultipart(request): TypedMultipart, + TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { - // No need for a transaction as document versions are immutable - let parent_document = state + let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; + let content = request.content.contents.to_vec(); + + update_document( + parent_document, + vault_id, + document_id, + user, + device_id, + state, + &request.relative_path, + content, + ) + .await +} + +#[axum::debug_handler] +#[allow(clippy::too_many_lines)] +pub async fn update_text( + Path(UpdateDocumentPathParams { + vault_id, + document_id, + }): Path, + Extension(user): Extension, + TypedHeader(device_id): TypedHeader, + State(state): State, + Json(request): Json, +) -> Result, SyncServerError> { + let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; + + let edited_text = EditedText::from_diff( + str::from_utf8(&parent_document.content) + .expect("parent must be valid UTF-8 because it's a text document"), + request.content, + &*BuiltinTokenizer::Word, + ) + .context("Failed to apply given diff to parent document") + .map_err(client_error)?; + + let content = edited_text.apply().text().into_bytes(); + + update_document( + parent_document, + vault_id, + document_id, + user, + device_id, + state, + &request.relative_path, + content, + ) + .await +} + +async fn get_parent_document( + state: &AppState, + vault_id: &VaultId, + parent_version_id: VaultUpdateId, +) -> Result { + state .database - .get_document_version(&vault_id, request.parent_version_id, None) + .get_document_version(vault_id, parent_version_id, None) .await .map_err(server_error)? .map_or_else( || { Err(not_found_error(anyhow!( - "Parent version with id `{}` not found", - request.parent_version_id + "Parent version with id `{parent_version_id}` not found" ))) }, Ok, - )?; + ) +} - let sanitized_relative_path = sanitize_path(&request.relative_path); +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +async fn update_document( + parent_document: StoredDocumentVersion, + vault_id: VaultId, + document_id: DocumentId, + user: User, + device_id: DeviceIdHeader, + state: AppState, + relative_path: &str, + content: Vec, +) -> Result, SyncServerError> { + debug!("Updating document `{document_id}` in vault `{vault_id}`"); + + let sanitized_relative_path = sanitize_path(relative_path); let mut transaction = state .database @@ -97,18 +168,19 @@ pub async fn update_document( .context("Failed to roll back transaction") .map_err(server_error)?; + info!("Document `{document_id}` has been deleted, ignoring update to it",); return Ok(Json(DocumentUpdateResponse::FastForwardUpdate( latest_version.into(), ))); } - let content = request.content.contents.to_vec(); - // Return the latest version if the content and path are the same as the latest // version if content == latest_version.content && sanitized_relative_path == latest_version.relative_path { - info!("Document content is the same as the latest version, skipping update"); + info!( + "Document content is the same as the latest version for `{document_id}`, skipping update" + ); transaction .rollback() .await @@ -120,12 +192,15 @@ pub async fn update_document( ))); } - let are_all_participants_mergable = is_file_type_mergable(&sanitized_relative_path) - && !is_binary(&parent_document.content) + let are_all_participants_mergable = is_file_type_mergable( + &sanitized_relative_path, + &state.config.server.mergeable_file_extensions, + ) && !is_binary(&parent_document.content) && !is_binary(&latest_version.content) && !is_binary(&content); let merged_content = if are_all_participants_mergable { + info!("Merging changes for document `{document_id}` in vault `{vault_id}`"); reconcile( str::from_utf8(&parent_document.content) .expect("parent must be valid UTF-8 because it's not binary"), @@ -150,21 +225,22 @@ pub async fn update_document( let new_relative_path = if parent_document.relative_path == latest_version.relative_path && latest_version.relative_path != sanitized_relative_path { - let mut new_relative_path = String::default(); - for candidate in dedup_paths(&sanitized_relative_path) { - if state - .database - .get_latest_document_by_path(&vault_id, &candidate, Some(&mut transaction)) - .await - .map_err(server_error)? - .is_none() - { - new_relative_path = candidate; - break; - } + let new_path = find_first_available_path( + &vault_id, + &sanitized_relative_path, + &state.database, + &mut transaction, + ) + .await + .map_err(server_error)?; + + if new_path != sanitized_relative_path { + info!( + "Document already exists at new location: `{sanitized_relative_path}` when trying to update it in vault `{vault_id}`, deconflicting by creating at `{new_path}`" + ); } - new_relative_path + new_path } else { latest_version.relative_path.clone() }; diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index 5e94b277..bb10b49f 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -43,12 +43,12 @@ pub async fn websocket_handler( } async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId) { - info!("WebSocket connection opened on vault '{vault_id}'"); + info!("WebSocket connection opened on vault `{vault_id}`"); let result = websocket(state, stream, vault_id.clone()).await; if let Err(err) = result { - debug!("WebSocket connection error on vault '{vault_id}': {err}"); + debug!("WebSocket connection error on vault `{vault_id}`: {err}"); } } @@ -71,7 +71,7 @@ async fn websocket( )?; info!( - "WebSocket handshake successful for vault '{vault_id}' for '{}'", + "WebSocket handshake successful for vault `{vault_id}` for `{}`", authed_handshake.handshake.device_id ); @@ -184,7 +184,7 @@ async fn websocket( if result.is_err() { info!( - "WebSocket disconnected on vault '{vault_id}' for '{}'", + "WebSocket disconnected on vault `{vault_id}` for `{}`", authed_handshake.handshake.device_id ); } diff --git a/sync-server/src/utils.rs b/sync-server/src/utils.rs index 010524de..b501ecb2 100644 --- a/sync-server/src/utils.rs +++ b/sync-server/src/utils.rs @@ -1,4 +1,8 @@ pub mod dedup_paths; +pub mod find_first_available_path; +pub mod is_binary; pub mod is_file_type_mergable; +pub mod log_level; pub mod normalize; +pub mod rotating_file_writer; pub mod sanitize_path; diff --git a/sync-server/src/utils/dedup_paths.rs b/sync-server/src/utils/dedup_paths.rs index c35ad33b..bc687f6a 100644 --- a/sync-server/src/utils/dedup_paths.rs +++ b/sync-server/src/utils/dedup_paths.rs @@ -9,16 +9,24 @@ pub fn dedup_paths(path: &str) -> impl Iterator { directory.push('/'); } - let name_parts = file_name.rsplitn(2, '.').collect::>(); - let mut reverse_parts = name_parts.into_iter().rev(); - let (stem, extension) = match (reverse_parts.next(), reverse_parts.next()) { - (Some(stem), maybe_extension) => ( - stem.to_owned(), - maybe_extension - .map(|ext| format!(".{ext}")) - .unwrap_or_default(), - ), - _ => unreachable!("Path must have at least one part"), + // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should split as ".config" + ".json" + let is_simple_dotfile = file_name.starts_with('.') && file_name.matches('.').count() == 1; + + let (stem, extension) = if is_simple_dotfile { + (file_name.clone(), String::new()) + } else { + // Regular file or dotfile with extension + let name_parts = file_name.rsplitn(2, '.').collect::>(); + let mut reverse_parts = name_parts.into_iter().rev(); + match (reverse_parts.next(), reverse_parts.next()) { + (Some(stem), maybe_extension) => ( + stem.to_owned(), + maybe_extension + .map(|ext| format!(".{ext}")) + .unwrap_or_default(), + ), + _ => unreachable!("Path must have at least one part"), + } }; let regex = Regex::new(r" \((\d+)\)$").unwrap(); @@ -85,4 +93,58 @@ mod test { Some("my/path.with.dots/file (6)".to_owned()) ); } + + #[test] + fn test_regex_capturing_group() { + // Single digit in parentheses + let mut deduped = dedup_paths("document (5).md"); + assert_eq!(deduped.next(), Some("document (5).md".to_owned())); + assert_eq!(deduped.next(), Some("document (6).md".to_owned())); + assert_eq!(deduped.next(), Some("document (7).md".to_owned())); + + // Multi-digit number + let mut deduped = dedup_paths("report (123).pdf"); + assert_eq!(deduped.next(), Some("report (123).pdf".to_owned())); + assert_eq!(deduped.next(), Some("report (124).pdf".to_owned())); + assert_eq!(deduped.next(), Some("report (125).pdf".to_owned())); + + // Number without extension + let mut deduped = dedup_paths("folder (99)"); + assert_eq!(deduped.next(), Some("folder (99)".to_owned())); + assert_eq!(deduped.next(), Some("folder (100)".to_owned())); + assert_eq!(deduped.next(), Some("folder (101)".to_owned())); + } + + #[test] + fn test_dedup_dotfiles() { + // Simple dotfile (no extension) + let mut deduped = dedup_paths(".gitignore"); + assert_eq!(deduped.next(), Some(".gitignore".to_owned())); + assert_eq!(deduped.next(), Some(".gitignore (1)".to_owned())); + assert_eq!(deduped.next(), Some(".gitignore (2)".to_owned())); + + // Dotfile with extension + let mut deduped = dedup_paths(".config.json"); + assert_eq!(deduped.next(), Some(".config.json".to_owned())); + assert_eq!(deduped.next(), Some(".config (1).json".to_owned())); + assert_eq!(deduped.next(), Some(".config (2).json".to_owned())); + + // Dotfile with number + let mut deduped = dedup_paths(".gitignore (5)"); + assert_eq!(deduped.next(), Some(".gitignore (5)".to_owned())); + assert_eq!(deduped.next(), Some(".gitignore (6)".to_owned())); + assert_eq!(deduped.next(), Some(".gitignore (7)".to_owned())); + + // Dotfile with extension and number + let mut deduped = dedup_paths(".config (3).json"); + assert_eq!(deduped.next(), Some(".config (3).json".to_owned())); + assert_eq!(deduped.next(), Some(".config (4).json".to_owned())); + assert_eq!(deduped.next(), Some(".config (5).json".to_owned())); + + // Dotfile in subdirectory + let mut deduped = dedup_paths("my/path/.gitignore"); + assert_eq!(deduped.next(), Some("my/path/.gitignore".to_owned())); + assert_eq!(deduped.next(), Some("my/path/.gitignore (1)".to_owned())); + assert_eq!(deduped.next(), Some("my/path/.gitignore (2)".to_owned())); + } } diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs new file mode 100644 index 00000000..7629d8f1 --- /dev/null +++ b/sync-server/src/utils/find_first_available_path.rs @@ -0,0 +1,26 @@ +use crate::app_state::database::models::VaultId; +use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths}; +use anyhow::Result; +use log::{debug, info}; + +pub async fn find_first_available_path( + vault_id: &VaultId, + sanitized_relative_path: &str, + database: &crate::app_state::database::Database, + transaction: &mut Transaction<'_>, +) -> Result { + info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`"); + for candidate in dedup_paths(sanitized_relative_path) { + debug!("Checking candidate path for deconflicting names: `{candidate}`"); + if database + .get_latest_document_by_path(vault_id, &candidate, Some(transaction)) + .await? + .is_none() + { + info!("Selected available path: `{candidate}`"); + return Ok(candidate); + } + } + + unreachable!("dedup_paths produces infinite paths"); +} diff --git a/sync-server/src/utils/is_binary.rs b/sync-server/src/utils/is_binary.rs new file mode 100644 index 00000000..09bfcf94 --- /dev/null +++ b/sync-server/src/utils/is_binary.rs @@ -0,0 +1,26 @@ +/// Heuristically determine if the given data is a binary or a text file's +/// content. +/// +/// Only text inputs can be reconciled using the crate's functions. +#[must_use] +pub fn is_binary(data: &[u8]) -> bool { + if data.contains(&0) { + // Even though the NUL character is valid in UTF-8, it's highly suspicious in + // human-readable text. + return true; + } + + std::str::from_utf8(data).is_err() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_binary() { + assert!(is_binary(&[0, 159, 146, 150])); + assert!(is_binary(&[0, 12])); + assert!(!is_binary(b"hello")); + } +} diff --git a/sync-server/src/utils/is_file_type_mergable.rs b/sync-server/src/utils/is_file_type_mergable.rs index fba4b323..7aabb393 100644 --- a/sync-server/src/utils/is_file_type_mergable.rs +++ b/sync-server/src/utils/is_file_type_mergable.rs @@ -1,7 +1,10 @@ -pub fn is_file_type_mergable(path_or_file_name: &str) -> bool { +pub fn is_file_type_mergable(path_or_file_name: &str, mergeable_extensions: &[String]) -> bool { let file_extension = path_or_file_name.split('.').next_back().unwrap_or_default(); + let file_extension_lower = file_extension.to_lowercase(); - matches!(file_extension.to_lowercase().as_str(), "md" | "txt") + mergeable_extensions + .iter() + .any(|ext| ext.to_lowercase() == file_extension_lower) } #[cfg(test)] @@ -10,14 +13,22 @@ mod tests { #[test] fn test_is_file_type_mergable() { - assert!(is_file_type_mergable(".md")); - assert!(is_file_type_mergable("hi.md")); - assert!(is_file_type_mergable("my/path/to/my/document.md")); - assert!(is_file_type_mergable("hi.MD")); - assert!(is_file_type_mergable("my/path/to/my/DOCUMENT.MD")); + let mergeable = vec!["md".to_owned(), "txt".to_owned()]; - assert!(!is_file_type_mergable(".json")); - assert!(!is_file_type_mergable("HELLO.JSON")); - assert!(!is_file_type_mergable("my/config.yml")); + assert!(is_file_type_mergable(".md", &mergeable)); + assert!(is_file_type_mergable("hi.md", &mergeable)); + assert!(is_file_type_mergable( + "my/path/to/my/document.md", + &mergeable + )); + assert!(is_file_type_mergable("hi.MD", &mergeable)); + assert!(is_file_type_mergable( + "my/path/to/my/DOCUMENT.MD", + &mergeable + )); + + assert!(!is_file_type_mergable(".json", &mergeable)); + assert!(!is_file_type_mergable("HELLO.JSON", &mergeable)); + assert!(!is_file_type_mergable("my/config.yml", &mergeable)); } } diff --git a/sync-server/src/utils/log_level.rs b/sync-server/src/utils/log_level.rs new file mode 100644 index 00000000..01ba669e --- /dev/null +++ b/sync-server/src/utils/log_level.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, +} + +impl LogLevel { + pub fn as_tracing_level(self) -> tracing::Level { + match self { + Self::Error => tracing::Level::ERROR, + Self::Warn => tracing::Level::WARN, + Self::Info => tracing::Level::INFO, + Self::Debug => tracing::Level::DEBUG, + Self::Trace => tracing::Level::TRACE, + } + } + + pub fn is_debug_or_trace(self) -> bool { + matches!(self, Self::Debug | Self::Trace) + } +} diff --git a/sync-server/src/utils/rotating_file_writer.rs b/sync-server/src/utils/rotating_file_writer.rs new file mode 100644 index 00000000..f04f9ba9 --- /dev/null +++ b/sync-server/src/utils/rotating_file_writer.rs @@ -0,0 +1,393 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use chrono::{Local, NaiveDateTime}; +use tracing_subscriber::fmt::MakeWriter; + +#[derive(Clone)] +pub struct RotatingFileWriter { + inner: Arc>, +} + +struct RotatingFileWriterInner { + directory: PathBuf, + file_prefix: String, + rotation_duration: Duration, + current_file: Option, + next_rotation_time: SystemTime, +} + +impl RotatingFileWriter { + pub fn new( + directory: impl AsRef, + file_prefix: &str, + rotation_duration: Duration, + ) -> io::Result { + let directory = directory.as_ref().to_path_buf(); + + fs::create_dir_all(&directory)?; + + let next_rotation_time = + Self::calculate_next_rotation_time(&directory, file_prefix, rotation_duration); + + let inner = RotatingFileWriterInner { + directory, + file_prefix: file_prefix.to_owned(), + rotation_duration, + current_file: None, + next_rotation_time, + }; + + Ok(Self { + inner: Arc::new(Mutex::new(inner)), + }) + } + + /// Parse timestamp from log filename and return as `SystemTime` + fn parse_log_timestamp(filename: &str, file_prefix: &str) -> Option { + // Expected format: {prefix}.{timestamp}.log where timestamp is %Y-%m-%d_%H-%M-%S + let prefix_len = file_prefix.len() + 1; // +1 for the dot + let timestamp_str = filename.get(prefix_len..filename.len().checked_sub(4)?)?; + + let dt = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").ok()?; + let timestamp = dt.and_local_timezone(Local).single()?; + let secs: u64 = timestamp.timestamp().try_into().ok()?; + + Some(UNIX_EPOCH + Duration::from_secs(secs)) + } + + fn find_latest_log_file(directory: &Path, file_prefix: &str) -> Option { + fs::read_dir(directory) + .ok()? + .filter_map(Result::ok) + .filter_map(|entry| { + let filename = entry.file_name().into_string().ok()?; + let has_correct_prefix = filename.starts_with(file_prefix); + let has_log_extension = Path::new(&filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("log")); + + (has_correct_prefix && has_log_extension).then_some(filename) + }) + .max() + } + + fn calculate_next_rotation_time( + directory: &Path, + file_prefix: &str, + rotation_duration: Duration, + ) -> SystemTime { + Self::find_latest_log_file(directory, file_prefix) + .and_then(|filename| Self::parse_log_timestamp(&filename, file_prefix)) + .map_or_else(SystemTime::now, |last_rotation| { + last_rotation + rotation_duration + }) + } + + fn should_rotate(inner: &RotatingFileWriterInner) -> bool { + SystemTime::now() >= inner.next_rotation_time + } + + fn open_or_create_log_file(inner: &mut RotatingFileWriterInner) -> io::Result<()> { + // If we haven't reached rotation time and there's an existing log file, reuse it + if !Self::should_rotate(inner) + && let Some(latest_file) = + Self::find_latest_log_file(&inner.directory, &inner.file_prefix) + { + let filepath = inner.directory.join(&latest_file); + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&filepath)?; + + inner.current_file = Some(file); + return Ok(()); + } + + // Otherwise, create a new log file with current timestamp + Self::rotate(inner) + } + + fn rotate(inner: &mut RotatingFileWriterInner) -> io::Result<()> { + let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S"); + let filename = format!("{}.{}.log", inner.file_prefix, timestamp); + let filepath = inner.directory.join(filename); + + let file = OpenOptions::new() + .create(true) + .append(true) + .open(&filepath)?; + + inner.current_file = Some(file); + inner.next_rotation_time = SystemTime::now() + inner.rotation_duration; + + Ok(()) + } +} + +impl Write for RotatingFileWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut inner = self.inner.lock().unwrap(); + + if inner.current_file.is_none() { + Self::open_or_create_log_file(&mut inner)?; + } else if Self::should_rotate(&inner) { + Self::rotate(&mut inner)?; + } + + if let Some(ref mut file) = inner.current_file { + file.write(buf) + } else { + Err(io::Error::other("Failed to open log file")) + } + } + + fn flush(&mut self) -> io::Result<()> { + let mut inner = self.inner.lock().unwrap(); + if let Some(ref mut file) = inner.current_file { + file.flush() + } else { + Ok(()) + } + } +} + +impl<'a> MakeWriter<'a> for RotatingFileWriter { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn test_write_creates_log_file_and_directory() { + let temp_dir = std::env::temp_dir().join("test_write_creates_log_file_and_directory"); + let _ = fs::remove_dir_all(&temp_dir); + + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"test log message\n").unwrap(); + writer.flush().unwrap(); + + // Check that a log file was created + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert!(temp_dir.exists()); + assert_eq!(entries.len(), 1); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_rotation_after_duration() { + let temp_dir = std::env::temp_dir().join("test_rotation_after_duration"); + let _ = fs::remove_dir_all(&temp_dir); + + // Use a very short rotation duration + // Note: We need to wait at least 1 second between rotations since + // filename timestamps only have second precision + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_millis(500)).unwrap(); + + writer.write_all(b"first message\n").unwrap(); + writer.flush().unwrap(); + + // Wait for rotation time to pass (at least 1 second for different timestamp) + thread::sleep(Duration::from_millis(1100)); + + writer.write_all(b"second message\n").unwrap(); + writer.flush().unwrap(); + + // Check that two log files were created + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert_eq!(entries.len(), 2); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_calculate_next_rotation_time_no_existing_logs() { + let temp_dir = + std::env::temp_dir().join("test_calculate_next_rotation_time_no_existing_logs"); + let _ = fs::remove_dir_all(&temp_dir); + + fs::create_dir_all(&temp_dir).unwrap(); + + let before = SystemTime::now(); + let next_rotation = RotatingFileWriter::calculate_next_rotation_time( + &temp_dir, + "test", + Duration::from_secs(3600), + ); + let after = SystemTime::now(); + + // Should return current time (within a small window) + assert!(next_rotation >= before && next_rotation <= after + Duration::from_secs(1)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_calculate_next_rotation_time_with_existing_log() { + let temp_dir = + std::env::temp_dir().join("test_calculate_next_rotation_time_with_existing_log"); + let _ = fs::remove_dir_all(&temp_dir); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create a log file with a known timestamp + let timestamp_str = "2025-10-26_14-30-00"; + let filename = format!("test.{timestamp_str}.log"); + fs::write(temp_dir.join(&filename), b"test").unwrap(); + + let rotation_duration = Duration::from_secs(3600); + let next_rotation = + RotatingFileWriter::calculate_next_rotation_time(&temp_dir, "test", rotation_duration); + + // Parse the expected time + let expected_dt = + NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").unwrap(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); + let expected_duration = + Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); + let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; + + // Allow 1 second tolerance for timing differences + let diff = if next_rotation > expected_next { + next_rotation.duration_since(expected_next).unwrap() + } else { + expected_next.duration_since(next_rotation).unwrap() + }; + + assert!( + diff < Duration::from_secs(2), + "Expected {expected_next:?}, got {next_rotation:?}" + ); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_picks_latest_log_file() { + let temp_dir = std::env::temp_dir().join("test_picks_latest_log_file"); + let _ = fs::remove_dir_all(&temp_dir); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create multiple log files + fs::write(temp_dir.join("test.2025-10-26_10-00-00.log"), b"old").unwrap(); + fs::write(temp_dir.join("test.2025-10-26_14-00-00.log"), b"newer").unwrap(); + fs::write(temp_dir.join("test.2025-10-26_12-00-00.log"), b"middle").unwrap(); + + let rotation_duration = Duration::from_secs(3600); + let next_rotation = + RotatingFileWriter::calculate_next_rotation_time(&temp_dir, "test", rotation_duration); + + // Should use the latest file (2025-10-26_14-00-00) + let expected_dt = + NaiveDateTime::parse_from_str("2025-10-26_14-00-00", "%Y-%m-%d_%H-%M-%S").unwrap(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); + let expected_duration = + Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); + let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; + + let diff = if next_rotation > expected_next { + next_rotation.duration_since(expected_next).unwrap() + } else { + expected_next.duration_since(next_rotation).unwrap() + }; + + assert!(diff < Duration::from_secs(2)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_ignores_malformed_filenames() { + let temp_dir = std::env::temp_dir().join("test_ignores_malformed_filenames"); + let _ = fs::remove_dir_all(&temp_dir); + + fs::create_dir_all(&temp_dir).unwrap(); + + // Create log files with various malformed names + fs::write(temp_dir.join("test.invalid.log"), b"bad").unwrap(); + fs::write(temp_dir.join("test.log"), b"bad2").unwrap(); + fs::write( + temp_dir.join("other.2025-10-26_14-00-00.log"), + b"wrong prefix", + ) + .unwrap(); + fs::write(temp_dir.join("test.2025-10-26_14-00-00.txt"), b"wrong ext").unwrap(); + + let before = SystemTime::now(); + let next_rotation = RotatingFileWriter::calculate_next_rotation_time( + &temp_dir, + "test", + Duration::from_secs(3600), + ); + let after = SystemTime::now(); + + // Should fall back to current time since no valid logs exist + assert!(next_rotation >= before && next_rotation <= after + Duration::from_secs(1)); + + fs::remove_dir_all(&temp_dir).unwrap(); + } + + #[test] + fn test_restart_behavior() { + let temp_dir = std::env::temp_dir().join("test_restart_behavior"); + let _ = fs::remove_dir_all(&temp_dir); + + // Create initial writer and write some data + { + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"before restart\n").unwrap(); + writer.flush().unwrap(); + } + + // Simulate restart by creating a new writer + thread::sleep(Duration::from_millis(100)); + { + let mut writer = + RotatingFileWriter::new(&temp_dir, "test", Duration::from_secs(3600)).unwrap(); + writer.write_all(b"after restart\n").unwrap(); + writer.flush().unwrap(); + } + + // Should still have only one log file (no premature rotation) + let entries: Vec<_> = fs::read_dir(&temp_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.path().extension().is_some_and(|ext| ext == "log")) + .collect(); + + assert_eq!( + entries.len(), + 1, + "Should not create new log file on restart within rotation period" + ); + + fs::remove_dir_all(&temp_dir).unwrap(); + } +}