diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..58b87749 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(npm run test:*)", + "Bash(npm test)", + "Bash(cargo:*)", + "Bash(grep:*)", + "Bash(npm test:*)", + "Bash(npm run lint)", + "Bash(docker build:*)", + "Bash(docker image:*)", + "Bash(timeout 120 docker buildx build --platform linux/arm64 -t vault-link-arm64-test . --load)", + "Bash(docker run:*)", + "Bash(./scripts/build-sync-server-binaries.sh:*)", + "Bash(npm install:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..9c63a68d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# https://editorconfig.org + +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b445fda5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" + directories: ["/frontend"] + 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/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..e2421e27 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,40 @@ +name: Check + +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 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/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..c540f1e4 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +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-docker.yml b/.github/workflows/publish-docker.yml new file mode 100644 index 00000000..f9fee79b --- /dev/null +++ b/.github/workflows/publish-docker.yml @@ -0,0 +1,90 @@ +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/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml new file mode 100644 index 00000000..ed223780 --- /dev/null +++ b/.github/workflows/publish-plugin.yml @@ -0,0 +1,57 @@ +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/.gitignore b/.gitignore new file mode 100644 index 00000000..ef64105e --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# npm +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 + +*.log +*.sqlx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..88d395f5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "jest.jestCommandLine": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" npx jest", + "jest.rootPath": "plugin", + "files.exclude": { + "**/dist": true, + "**/node_modules": true, + "**/.sqlx": true, + "**/target": true, + }, +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e05e784a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Architecture + +### Core Components + +- **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 + +### Key Technologies + +- **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 + +## Development Commands + +### 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 +``` + +### Frontend Development +```bash +cd frontend +npm run dev # Start development mode (watches sync-client and obsidian-plugin) +npm run build # Build all workspaces +npm run test # Run all tests +npm run lint # Lint and format TypeScript code +``` + +### Database Setup (Development) +```bash +cd sync-server +sqlx database create --database-url sqlite://db.sqlite3 +sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 +cargo sqlx prepare --workspace +``` + +### 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 + +## Code Structure + +### Workspace Configuration +The frontend uses npm workspaces with three packages: +- `sync-client`: Core synchronization logic +- `obsidian-plugin`: Obsidian-specific integration +- `test-client`: Testing utilities + +### Type Generation +Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages. + +### 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 + +## Testing + +### Running Tests +- Server: `cargo test --verbose` +- Frontend: `npm run test` (runs Jest across all workspaces) +- E2E: `scripts/e2e.sh` + +### Test Structure +- Rust: Unit tests alongside source files +- TypeScript: `.test.ts` files using Jest +- E2E: Uses test-client to simulate multiple concurrent users + +## Code Style + +### Rust +- Uses extensive Clippy lints (see Cargo.toml) +- Follows pedantic linting rules +- Forbids unsafe code +- Uses cargo fmt with default settings + +### 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 diff --git a/README.md b/README.md new file mode 100644 index 00000000..1eb7a1c2 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# VaultLink self-hosted Obsidian plugin for file syncing + +[![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 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) + +- `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` + +### 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` + +### Install Obsidian on Linux + +```sh +apt install flatpak +flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo +flatpak install flathub md.obsidian.Obsidian +flatpak run md.obsidian.Obsidian +``` + +#### Run in development mode + +Start the server: + +```sh +cd sync-server && cargo run config-e2e.yml +``` + +```sh +cd frontend && npm run dev +``` + +### Scripts + +#### Update HTTP API TS bindings + +```sh +scripts/update-api-types.sh +``` + +#### Publish new version + +```sh +scripts/bump-version.sh patch +``` + +#### Run E2E tests + +```sh +scripts/e2e.sh +``` + +And to clean up the logs & database files, run `scripts/clean-up.sh` + +## Projects + +- [Sync server](./sync-server/README.md) diff --git a/frontend/.claude/settings.local.json b/frontend/.claude/settings.local.json new file mode 100644 index 00000000..7eef7e72 --- /dev/null +++ b/frontend/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(npm test:*)", + "Bash(npm ls:*)", + "Bash(find:*)", + "Bash(grep -n \"isBinary\" ../node_modules/reconcile-text/dist/reconcile.node.js)", + "Read(/home/andras/projects/obsidian-shared-sync/sync-server/**)", + "Read(/mnt/wsl/PHYSICALDRIVE1/projects/obsidian-shared-sync/sync-server/**)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 00000000..db648d46 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,62 @@ +import eslint from "@eslint/js"; +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 + } + } + }) +]; diff --git a/frontend/obsidian-plugin/.claude/settings.local.json b/frontend/obsidian-plugin/.claude/settings.local.json new file mode 100644 index 00000000..68c2c186 --- /dev/null +++ b/frontend/obsidian-plugin/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run test:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/frontend/obsidian-plugin/.hotreload b/frontend/obsidian-plugin/.hotreload new file mode 100644 index 00000000..e69de29b diff --git a/frontend/obsidian-plugin/README.md b/frontend/obsidian-plugin/README.md new file mode 100644 index 00000000..d7f694da --- /dev/null +++ b/frontend/obsidian-plugin/README.md @@ -0,0 +1,92 @@ +# Obsidian Sample Plugin + +This is a sample plugin for Obsidian (https://obsidian.md). + +This project uses TypeScript to provide type checking and documentation. +The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definition format, which contains TSDoc comments describing what it does. + +**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. +- Registers a global click event and output 'click' to the console. +- Registers a global interval which logs 'setInterval' to the console. + +## First time developing plugins? + +Quick starting guide for new plugin devs: + +- Check if [someone already developed a plugin for what you want](https://obsidian.md/plugins)! There might be an existing plugin similar enough that you can partner up with. +- Make a copy of this repo as a template with the "Use this template" button (login to GitHub if you don't see it). +- Clone your repo to a local development folder. For convenience, you can place this folder in your `.obsidian/plugins/your-plugin-name` folder. +- Install NodeJS, then run `npm i` in the command line under your repo folder. +- Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. +- Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. +- Reload Obsidian to load the new version of your plugin. +- Enable plugin in settings window. +- For updates to the Obsidian API run `npm update` in the command line under your repo folder. + +## Releasing new releases + +- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release. +- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible. +- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases +- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments. Note: The manifest.json file must be in two places, first the root path of your repository and also in the release. +- Publish the release. + +> You can simplify the version bump process by running `npm version patch`, `npm version minor` or `npm version major` after updating `minAppVersion` manually in `manifest.json`. +> The command will bump version in `manifest.json` and `package.json`, and add the entry for the new version to `versions.json` + +## Adding your plugin to the community plugin list + +- Check the [plugin guidelines](https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines). +- Publish an initial version. +- Make sure you have a `README.md` file in the root of your repo. +- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin. + +## How to use + +- Clone this repo. +- Make sure your NodeJS is at least v16 (`node --version`). +- `npm i` or `yarn` to install dependencies. +- `npm run dev` to start compilation in watch mode. + +## Manually installing the plugin + +- 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 new file mode 100644 index 00000000..21d6fa3e --- /dev/null +++ b/frontend/obsidian-plugin/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "vault-link", + "name": "VaultLink", + "version": "0.8.0", + "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 diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json new file mode 100644 index 00000000..c3a28ef4 --- /dev/null +++ b/frontend/obsidian-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "vault-link-obsidian-plugin", + "version": "0.8.0", + "description": "This is a sample plugin for Obsidian (https://obsidian.md)", + "main": "main.js", + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "echo \"no tests defined\" && exit 0", + "version": "node version-bump.mjs" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@plausible-analytics/tracker": "^0.4.0", + "@sentry/browser": "^10.8.0", + "@types/node": "^22.15.30", + "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" + } +} diff --git a/frontend/obsidian-plugin/src/obsidian-file-system.ts b/frontend/obsidian-plugin/src/obsidian-file-system.ts new file mode 100644 index 00000000..00a9acfb --- /dev/null +++ b/frontend/obsidian-plugin/src/obsidian-file-system.ts @@ -0,0 +1,174 @@ +import type { Stat, Vault, Workspace } from "obsidian"; +import { MarkdownView, normalizePath } from "obsidian"; +import { + 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 async listAllFiles(): Promise { + // Let's implement this by hand because vault.adapter.listAllFiles doesn't always return all files. + const allFiles = []; + const remainingFolders = [this.vault.getRoot().path]; + + // 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; + } + + const files = await this.vault.adapter.list(normalizePath(folder)); + allFiles.push(...files.files); + remainingFolders.push(...files.folders); + } + + 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()); + } + + return new Uint8Array(await this.vault.adapter.readBinary(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; + } + + 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); + + const view = this.workspace.getActiveViewOfType(MarkdownView); + + 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 result = updater({ + text, + cursors + }); + + if (result.text === text) { + return 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 { 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); + + return result.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 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 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 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)); + + if (!file) { + throw new Error(`File not found: ${path}`); + } + + return file; + } +} diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts new file mode 100644 index 00000000..ce3f23ac --- /dev/null +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -0,0 +1,287 @@ +import type { + MarkdownView, + Editor, + MarkdownFileInfo, + TAbstractFile, + WorkspaceLeaf +} from "obsidian"; +import { 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 +} 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 +} 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; + +export default class VaultLinkPlugin extends Plugin { + private readonly disposables: (() => unknown)[] = []; + + private settingsTab: SyncSettingsTab | undefined; + private client!: SyncClient; + private readonly rateLimitedUpdatesPerFile = new Map< + string, + () => Promise + >(); + + public async onload(): Promise { + DEFAULT_SETTINGS.ignorePatterns.push( + ".obsidian/**", + ".git/**", + ".trash/**" + ); + + const isDebugBuild = process.env.NODE_ENV === "development"; + + if (!isDebugBuild) { + plausibleInit({ + domain: "vault-link", + endpoint: "https://stats.schmelczer.dev/status", + autoCapturePageviews: true, + captureOnLocalhost: true, + logging: true + }); + + Sentry.init({ + dsn: "https://56accd39d92442e788a457a04623cf57@bugs.schmelczer.dev/1", + skipBrowserExtensionCheck: false + }); + + 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); + }); + + const onUnhandledRejection = ( + event: PromiseRejectionEvent + ): void => { + Sentry.captureException(event.reason); + }; + window.addEventListener("unhandledrejection", onUnhandledRejection); + this.disposables.push(() => { + window.removeEventListener( + "unhandledrejection", + onUnhandledRejection + ); + }); + } + + const debugOptions = isDebugBuild + ? { + fetch: debugging.slowFetchFactory(1), + webSocket: debugging.slowWebSocketFactory(1, new Logger()) + } + : {}; + + 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 + }); + + if (isDebugBuild) { + debugging.logToConsole(this.client); + } + + const statusDescription = new StatusDescription(this.client); + + this.settingsTab = new SyncSettingsTab({ + app: this.app, + plugin: this, + syncClient: this.client, + statusDescription + }); + this.addSettingTab(this.settingsTab); + + new StatusBar(this, this.client); + + this.registerView( + HistoryView.TYPE, + (leaf) => new HistoryView(this.client, leaf) + ); + + this.registerView( + LogsView.TYPE, + (leaf) => new LogsView(this.client, leaf) + ); + + this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]); + + this.client.addRemoteCursorsUpdateListener((cursors) => { + RemoteCursorsPluginValue.setCursors(cursors, this.app); + renderCursorsInFileExplorer(cursors, this.app); + }); + + const cursorListener = new LocalCursorUpdateListener( + this.client, + this.app.workspace + ); + this.disposables.push(() => { + cursorListener.dispose(); + }); + + this.app.workspace.updateOptions(); + + 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) + ); + + this.app.workspace.onLayoutReady(async () => { + this.registerEditorEvents(); + await this.client.start(); + + const editorStatusDisplayManager = new EditorStatusDisplayManager( + this, + this.app.workspace, + this.client + ); + this.disposables.push(() => { + editorStatusDisplayManager.stop(); + }); + }); + } + + 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(); + }); + } + + 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 + } + + public closeSettings(): void { + // eslint-disable-next-line + (this.app as any).setting.close(); // this is undocumented + } + + public async activateView(type: string): Promise { + const { workspace } = this.app; + + let leaf: WorkspaceLeaf | null = null; + const leaves = workspace.getLeavesOfType(type); + + if (leaves.length > 0) { + [leaf] = leaves; + } else { + leaf = workspace.getRightLeaf(false); + await leaf?.setViewState({ type: type, active: true }); + } + + if (leaf) { + await workspace.revealLeaf(leaf); + } + } + + 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); + }); + } + + 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)?.(); + } +} diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss b/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss new file mode 100644 index 00000000..90918b55 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.scss @@ -0,0 +1,15 @@ +.remote-users { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); + margin-left: var(--size-4-2); + + span { + border-radius: var(--radius-l); + padding: 0 var(--size-4-1); + border-width: 1.4px; + border-style: solid; + 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 new file mode 100644 index 00000000..be71c058 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -0,0 +1,55 @@ +import "./file-explorer.scss"; + +import type { App, View } from "obsidian"; +import { + utils, + type MaybeOutdatedClientCursors, + type RelativePath +} from "sync-client"; + +const REMOTE_USER_CONTAINER_CLASS = "remote-users"; + +export function renderCursorsInFileExplorer( + cursors: MaybeOutdatedClientCursors[], + app: App +): void { + const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); + if (fileExplorers.length == 0) return; + + 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 + + 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)}` + } + }) + ); + } + }); + }); + } + ); + + 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 new file mode 100644 index 00000000..1635b930 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/get-selections-from-editor.ts @@ -0,0 +1,17 @@ +import type { Editor } from "obsidian"; +import { utils } from "sync-client"; + +export interface Selection { + 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) + })); +} 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 new file mode 100644 index 00000000..da67c70d --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/local-cursor-update-listener.ts @@ -0,0 +1,50 @@ +import type { Workspace } from "obsidian"; +import { MarkdownView } from "obsidian"; +import type { SyncClient } from "sync-client"; +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; + + 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); + } + + 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; + } +} diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts new file mode 100644 index 00000000..3af2692d --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-theme.ts @@ -0,0 +1,63 @@ +import { EditorView } from "@codemirror/view"; + +const CARET_WIDTH = 2; +const DOT_RADIUS = 4; + +export const remoteCursorsTheme = EditorView.baseTheme({ + ".selection-caret": { + position: "relative" + }, + + ".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" + }, + + "@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: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: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 new file mode 100644 index 00000000..e3273484 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursor-widget.ts @@ -0,0 +1,46 @@ +import { AnnotationType, Annotation, RangeSet, Range } from "@codemirror/state"; +import { + 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 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; + } +} diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts new file mode 100644 index 00000000..a0de390c --- /dev/null +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -0,0 +1,236 @@ +import type { Range } from "@codemirror/state"; +import { RangeSet } from "@codemirror/state"; +import { ViewPlugin, Decoration } from "@codemirror/view"; + +import type { + PluginValue, + DecorationSet, + EditorView, + ViewUpdate +} from "@codemirror/view"; +import { RemoteCursorWidget } from "./remote-cursor-widget"; +import { + utils, + type CursorSpan, + type MaybeOutdatedClientCursors +} from "sync-client"; +import type { App } from "obsidian"; +import { MarkdownView } from "obsidian"; + +import { StateEffect } from "@codemirror/state"; +import type { SpanWithHistory } from "reconcile-text"; +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; + }[] = []; + + public decorations: DecorationSet = RangeSet.of([]); + + public static setCursors( + clients: MaybeOutdatedClientCursors[], + app: App + ): void { + 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 } + })) + ); + }) + ]; + + 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)] + }); + }); + } + + 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 + ); + + 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]; + }); + } + + 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; + } + + public update(update: ViewUpdate): void { + const original = update.startState.doc.toString(); + const edited = update.state.doc.toString(); + + RemoteCursorsPluginValue.interpolateRemoteCursorPositions( + original, + edited + ); + + const decorations: Range[] = []; + + RemoteCursorsPluginValue.cursors.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};` + }; + + 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 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) + }) + }); + } + ); + + this.decorations = Decoration.set(decorations, true); + } +} + +export const remoteCursorsPlugin = ViewPlugin.fromClass( + 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 new file mode 100644 index 00000000..a430ac3b --- /dev/null +++ b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.scss @@ -0,0 +1,43 @@ +.vault-link-sync-status { + 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); + + top: 50%; + left: 0; + transform: translateY(-50%) translateX(-100%) translateY(-2px); + transition: opacity 200ms; + } + + &:hover { + > span { + opacity: 1; + } + } + + > .icon { + line-height: 0; + } + + &.loading > .icon { + animation: spin 2s linear infinite; + + @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 new file mode 100644 index 00000000..5075b847 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/editor-status-display-manager/editor-status-display-manager.ts @@ -0,0 +1,97 @@ +import type { Workspace } from "obsidian"; +import { FileView, setIcon } from "obsidian"; +import type { SyncClient } from "sync-client"; +import { DocumentSyncStatus } from "sync-client"; +import "./editor-status-display-manager.scss"; +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 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 stop(): 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; + } + + 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); + + if (currentStatus == DocumentSyncStatus.SYNCING_IS_DISABLED) { + element.remove(); + return; + } + + 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" + ); + } + } + }); + } + + 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); + } + ) + ); + } +} diff --git a/frontend/obsidian-plugin/src/views/history/history-view.scss b/frontend/obsidian-plugin/src/views/history/history-view.scss new file mode 100644 index 00000000..6033fd2b --- /dev/null +++ b/frontend/obsidian-plugin/src/views/history/history-view.scss @@ -0,0 +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; + + &.clickable { + cursor: pointer; + } + + &.success { + background-color: rgba(var(--color-green-rgb), 0.2); + } + + &.error { + background-color: rgba(var(--color-red-rgb), 0.2); + } + + &.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); + + @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; + + > 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-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 new file mode 100644 index 00000000..631fde72 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/history/history-view.ts @@ -0,0 +1,234 @@ +import "./history-view.scss"; + +import type { IconName, WorkspaceLeaf } from "obsidian"; +import { ItemView, setIcon } from "obsidian"; +import { intlFormatDistance } from "date-fns"; +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; + + 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; + + this.client.addSyncHistoryUpdateListener(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 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$/, ""); + + 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" + ); + + 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; + } + + public getViewType(): string { + return HistoryView.TYPE; + } + + public getDisplayText(): string { + return "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" }); + + await this.updateView(); + 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); + } + } + + private async updateView(): Promise { + const container = this.historyContainer; + if (container === undefined) { + return; + } + + // entries are newest first, but we prepend new ones + const entries = this.client.getHistoryEntries().toReversed(); + + if (this.historyEntryToElement.size === 0 && entries.length > 0) { + // Clear the "No update has happened yet" message + container.empty(); + } + + entries.forEach((entry) => { + const element = this.historyEntryToElement.get(entry); + if (element !== undefined) { + HistoryView.updateTimeSince(element, entry); + return; + } + + const newElement = this.createHistoryCard(container, entry); + container.prepend(newElement); + this.historyEntryToElement.set(entry, newElement); + }); + + const newEntries = new Set(entries); + for (const [entry, element] of this.historyEntryToElement) { + if (!newEntries.has(entry)) { + element.remove(); + this.historyEntryToElement.delete(entry); + } + } + + if (entries.length === 0) { + container.empty(); + container.createEl("p", { + text: "No update has happened yet." + }); + } + } + + 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.addClass("clickable"); + } + + card.createDiv( + { + cls: "history-card-header" + }, + (header) => { + header.createEl( + "h5", + { + cls: "history-card-title" + }, + (title) => { + HistoryView.renderSyncItemTitle(title, entry); + } + ); + + header.createSpan({ + text: HistoryView.getTimestampAndAuthor(entry), + cls: "history-card-timestamp" + }); + } + ); + + 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 new file mode 100644 index 00000000..82ed1037 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/logs/logs-view.scss @@ -0,0 +1,60 @@ +.logs-view { + 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); + + h4 { + margin: 0; + } + + select { + cursor: pointer; + } + } + + .logs-container { + max-width: 100%; + overflow-y: auto; + + .log-message { + font: var(--font-monospace); + margin-bottom: var(--size-2-1); + overflow-wrap: break-word; + white-space: pre-wrap; + user-select: all; + + .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); + } + + &.DEBUG { + color: var(--color-base-50); + } + + &.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 new file mode 100644 index 00000000..19cf4701 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/logs/logs-view.ts @@ -0,0 +1,152 @@ +import "./logs-view.scss"; + +import type { WorkspaceLeaf } from "obsidian"; +import { ItemView } 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"; + + 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; + + public constructor( + private readonly client: SyncClient, + leaf: WorkspaceLeaf + ) { + super(leaf); + this.icon = LogsView.ICON; + this.client.logger.addOnMessageListener(() => { + 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 formatTimestamp(timestamp: Date): string { + return timestamp.toTimeString().split(" ")[0]; + } + + public getViewType(): string { + return LogsView.TYPE; + } + + public getDisplayText(): string { + return "VaultLink logs"; + } + + 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 } + ]; + + 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 }) + ); + + dropdown.value = this.minLogLevel; + + dropdown.addEventListener("change", () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + this.minLogLevel = dropdown.value as LogLevel; + + this.logsContainer?.empty(); + this.logLineToElement.clear(); + this.updateView(); + }); + }); + } + ); + + this.logsContainer = container.createDiv({ cls: "logs-container" }); + + this.updateView(); + } + + private updateView(): void { + const container = this.logsContainer; + if (container === undefined) { + return; + } + + const logs = this.client.logger.getMessages(this.minLogLevel); + + 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 new file mode 100644 index 00000000..dcc3e806 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.scss @@ -0,0 +1,57 @@ +@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); + + &.good { + background-color: rgba(var(--color-green-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); + + .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); + } + } + + .button-container { + display: flex; + gap: var(--size-4-2); + } + + h3 { + font-size: var(--font-ui-large); + margin-top: var(--heading-spacing); + } + + button, + input[type="range"], + .checkbox-container, + .slider::-webkit-slider-thumb { + cursor: pointer; + } + + input[type="text"], + textarea { + width: 250px; + } + + textarea { + resize: none; + height: 75px; + } +} diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts new file mode 100644 index 00000000..2d129edc --- /dev/null +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -0,0 +1,384 @@ +import "./settings-tab.scss"; + +import type { App } from "obsidian"; +import { Notice, PluginSettingTab, Setting } from "obsidian"; +import type VaultLinkPlugin from "src/vault-link-plugin"; +import type { SyncClient, SyncSettings } from "sync-client"; +import { HistoryView } from "../history/history-view"; +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 readonly plugin: VaultLinkPlugin; + private readonly syncClient: SyncClient; + private readonly statusDescription: StatusDescription; + private statusDescriptionSubscription: (() => unknown) | 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; + + this.editedServerUri = this.syncClient.getSettings().remoteUri; + this.editedToken = this.syncClient.getSettings().token; + this.editedVaultName = this.syncClient.getSettings().vaultName; + + this.syncClient.addOnSettingsChangeListener( + (newSettings, oldSettings) => { + let hasChanged = false; + + if (newSettings.remoteUri !== oldSettings.remoteUri) { + this.editedServerUri = newSettings.remoteUri; + hasChanged = true; + } + + if (newSettings.token !== oldSettings.token) { + this.editedToken = newSettings.token; + hasChanged = true; + } + + if (newSettings.vaultName !== oldSettings.vaultName) { + this.editedVaultName = newSettings.vaultName; + hasChanged = true; + } + + if (hasChanged) { + this.display(); + } + } + ); + } + + public display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass("vault-link-settings"); + + this.renderSettingsHeader(containerEl); + this.renderConnectionSettings(containerEl); + this.renderSyncSettings(containerEl); + } + + public hide(): void { + super.hide(); + this.setStatusDescriptionSubscription(); + } + + private renderSettingsHeader(containerEl: HTMLElement): void { + containerEl.createEl("h2", { text: "VaultLink" }).createSpan({ + text: this.plugin.manifest.version, + cls: "version" + }); + + containerEl.createDiv( + { + cls: "description" + }, + (descriptionContainer) => { + this.setStatusDescriptionSubscription( + this.statusDescription.renderStatusDescription.bind( + this.statusDescription, + descriptionContainer + ) + ); + } + ); + + 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); + }) + ); + + buttonContainer.createEl( + "button", + { + text: "Show logs" + }, + (button) => + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(LogsView.TYPE); + }) + ); + } + ); + } + + private renderConnectionSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Connection" }); + + 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 [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()); + }) + ); + + 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()); + }) + ); + + 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" + ); + } + + new Notice( + (await this.syncClient.checkConnection()).serverMessage + ); + await this.statusDescription.updateConnectionState(); + }) + ); + } + + private areThereUnsavedChanges(): boolean { + return ( + this.editedServerUri !== this.syncClient.getSettings().remoteUri || + this.editedToken !== this.syncClient.getSettings().token || + this.editedVaultName !== this.syncClient.getSettings().vaultName + ); + } + + private renderSyncSettings(containerEl: HTMLElement): void { + containerEl.createEl("h3", { text: "Sync" }); + + 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) + ) + ); + + 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("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) + ) + ); + + 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.setButtonText("Reset sync state").onClick(async () => { + await this.syncClient.reset(); + new Notice( + "Sync state has been reset, you will need to resync" + ); + }) + ); + } + + 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 new file mode 100644 index 00000000..3762c2d9 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/status-bar/status-bar.scss @@ -0,0 +1,14 @@ +.sync-status { + display: flex; + gap: var(--size-4-2); + + * { + display: block; + } + + .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 new file mode 100644 index 00000000..6466601c --- /dev/null +++ b/frontend/obsidian-plugin/src/views/status-bar/status-bar.ts @@ -0,0 +1,75 @@ +import "./status-bar.scss"; + +import type { HistoryStats, SyncClient } from "sync-client"; +import type VaultLinkPlugin from "../../vault-link-plugin"; + +export class StatusBar { + private readonly statusBarItem: HTMLElement; + + 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(); + }); + + this.syncClient.addRemainingSyncOperationsListener( + (remainingOperations) => { + this.lastRemaining = remainingOperations; + this.updateStatus(); + } + ); + + this.syncClient.addOnSettingsChangeListener(() => { + this.updateStatus(); + }); + } + + 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); + + return; + } + + let hasShownMessage = false; + + 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?.error ?? 0) > 0) { + hasShownMessage = true; + container.createSpan({ + text: `${this.lastHistoryStats?.error ?? 0} ❌` + }); + } + + 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 new file mode 100644 index 00000000..3ac86944 --- /dev/null +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.scss @@ -0,0 +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); + + &.good { + background-color: rgba(var(--color-green-rgb), 0.35); + } + + &.bad { + background-color: rgba(var(--color-red-rgb), 0.35); + } +} + +.status-description { + margin: var(--p-spacing) 0; + + .number { + @include number-card; + font-family: var(--font-monospace); + font-weight: var(--bold-weight); + } + + .error { + color: rgb(var(--color-red-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 new file mode 100644 index 00000000..666c107b --- /dev/null +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -0,0 +1,148 @@ +import "./status-description.scss"; + +import type { + HistoryStats, + NetworkConnectionStatus, + SyncClient +} from "sync-client"; + +export class StatusDescription { + private lastHistoryStats: HistoryStats | undefined; + private lastRemaining: number | undefined; + private lastConnectionState: NetworkConnectionStatus | undefined; + + private statusChangeListeners: (() => unknown)[] = []; + + public constructor(private readonly syncClient: SyncClient) { + void this.updateConnectionState(); + + syncClient.addSyncHistoryUpdateListener((status) => { + this.lastHistoryStats = status; + this.updateDescription(); + }); + + this.syncClient.addRemainingSyncOperationsListener( + (remainingOperations) => { + this.lastRemaining = remainingOperations; + this.updateDescription(); + } + ); + + this.syncClient.addWebSocketStatusChangeListener(async () => + this.updateConnectionState() + ); + + this.syncClient.addOnSettingsChangeListener(async () => + this.updateConnectionState() + ); + } + + 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 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.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; + } + + 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. ` + }); + + 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." + }); + } + + private updateDescription(): void { + this.statusChangeListeners.forEach((listener) => { + listener(); + }); + } +} diff --git a/frontend/obsidian-plugin/tsconfig.json b/frontend/obsidian-plugin/tsconfig.json new file mode 100644 index 00000000..4c39e97b --- /dev/null +++ b/frontend/obsidian-plugin/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "ESNext", + "target": "ES2023", + "strict": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "lib": [ + "DOM", + "ES2024" + ] + }, + "exclude": [ + "./dist" + ] +} \ No newline at end of file diff --git a/frontend/obsidian-plugin/version-bump.mjs b/frontend/obsidian-plugin/version-bump.mjs new file mode 100644 index 00000000..f8b25824 --- /dev/null +++ b/frontend/obsidian-plugin/version-bump.mjs @@ -0,0 +1,7 @@ +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js new file mode 100644 index 00000000..8a193c3e --- /dev/null +++ b/frontend/obsidian-plugin/webpack.config.js @@ -0,0 +1,117 @@ +const path = require("path"); +const TerserPlugin = require("terser-webpack-plugin"); +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; + } + + compiler.hooks.done.tap("Copy Files Plugin", (stats) => { + const source = path.resolve(__dirname, "dist"); + const destinations = [ + "/mnt/c/Users/Andras/Desktop/test/test/.obsidian/plugins/vault-link", + "/mnt/c/Users/Andras/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) + ); + + 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 new file mode 100644 index 00000000..08b07625 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4724 @@ +{ + "name": "my-workspace", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "my-workspace", + "workspaces": [ + "sync-client", + "obsidian-plugin", + "test-client" + ], + "devDependencies": { + "concurrently": "^9.1.2", + "eslint": "9.28.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^18.0.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" + } + }, + "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.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.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", + "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.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "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/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", + "dev": true, + "license": "MIT" + }, + "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", + "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": "18.0.1", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" + }, + "engines": { + "node": "^18.18.0 || >=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.2", + "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.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "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.8.0", + "license": "MIT", + "devDependencies": { + "@plausible-analytics/tracker": "^0.4.0", + "@sentry/browser": "^10.8.0", + "@types/node": "^22.15.30", + "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" + } + }, + "obsidian-plugin/node_modules/@types/node": { + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "obsidian-plugin/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "sync-client": { + "version": "0.8.0", + "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": "^22.15.30", + "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/@types/node": { + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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" + } + }, + "sync-client/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "test-client": { + "version": "0.8.0", + "bin": { + "test-client": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "bufferutil": "^4.0.9", + "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" + } + }, + "test-client/node_modules/@types/node": { + "version": "22.18.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", + "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "test-client/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..718efea1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "my-workspace", + "private": true, + "workspaces": [ + "sync-client", + "obsidian-plugin", + "test-client" + ], + "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 && prettier --write \"**/*.ts\"", + "update": "ncu -u -ws" + }, + "devDependencies": { + "concurrently": "^9.1.2", + "eslint": "9.28.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^18.0.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" + } +} \ No newline at end of file diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json new file mode 100644 index 00000000..0a76c4bf --- /dev/null +++ b/frontend/sync-client/package.json @@ -0,0 +1,33 @@ +{ + "name": "sync-client", + "version": "0.8.0", + "main": "dist/sync-client.node.js", + "browser": "dist/sync-client.web.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "tsx --test src/**/*.test.ts" + }, + "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": "^22.15.30", + "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" + } +} diff --git a/frontend/sync-client/src/debugging/log-to-console.ts b/frontend/sync-client/src/debugging/log-to-console.ts new file mode 100644 index 00000000..ace58db0 --- /dev/null +++ b/frontend/sync-client/src/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.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 new file mode 100644 index 00000000..cd07dd1a --- /dev/null +++ b/frontend/sync-client/src/debugging/slow-fetch-factory.ts @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..51a27a5f --- /dev/null +++ b/frontend/sync-client/src/debugging/slow-web-socket-factory.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 00000000..63af7dab --- /dev/null +++ b/frontend/sync-client/src/file-operations/file-not-found-error.ts @@ -0,0 +1,6 @@ +export class FileNotFoundError extends Error { + public constructor(message: 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 new file mode 100644 index 00000000..64c02655 --- /dev/null +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -0,0 +1,160 @@ +import { describe, it } from "node:test"; +import type { + 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"; + +class MockDatabase implements Partial { + public getLatestDocumentByRelativePath( + _find: RelativePath + ): DocumentRecord | undefined { + // no-op + return undefined; + } + + public move( + _oldRelativePath: RelativePath, + _newRelativePath: RelativePath + ): void { + // no-op + } +} + +class FakeFileSystemOperations implements FileSystemOperations { + public readonly names = new Set(); + + public async listAllFiles(): Promise { + throw new Error("Method not implemented."); + } + 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 + ); + + 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.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)" + ); + }); + + 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 + ); + + 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("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 + ); + + 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)" + ); + }); +}); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts new file mode 100644 index 00000000..38f624e5 --- /dev/null +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -0,0 +1,215 @@ +import type { Logger } from "../tracing/logger"; +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 { isFileTypeMergable } from "../utils/is-file-type-mergable"; +export class FileOperations { + 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 async listAllFiles(): Promise { + return this.fs.listAllFiles(); + } + + 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)); + } + + 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}'` + ); + + this.database.move(path, deconflictedPath); + await this.fs.rename(path, 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; + } + + 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; + } + + 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` + ); + + text = text.replace(this.nativeLineEndings, "\n"); + const merged = reconcile( + expectedText, + { text, cursors }, + newText + ); + + const resultText = merged.text.replace( + "\n", + this.nativeLineEndings + ); + + return { + text: resultText, + cursors: merged.cursors + }; + } + ); + } + + public async delete(path: RelativePath): Promise { + if (await this.exists(path)) { + return this.fs.delete(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 exists(path: RelativePath): Promise { + return this.fs.exists(path); + } + + public async move( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + if (oldPath === newPath) { + return; + } + + await this.ensureClearPath(newPath); + + this.database.move(oldPath, newPath); + await this.fs.rename(oldPath, newPath); + } + + private fromNativeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } + + const decoder = new TextDecoder("utf-8"); + let text = decoder.decode(content); + text = text.replace(this.nativeLineEndings, "\n"); + return new TextEncoder().encode(text); + } + + private toNativeLineEndings(content: Uint8Array): Uint8Array { + if (isBinary(content)) { + return content; + } + + const decoder = new TextDecoder("utf-8"); + let text = decoder.decode(content); + text = text.replace("\n", this.nativeLineEndings); + return new TextEncoder().encode(text); + } + + 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); + } + } + } + + private async deconflictPath(path: RelativePath): Promise { + const pathParts = path.split("/"); + const fileName = pathParts.pop(); + if (fileName == "" || fileName == null) { + throw new Error(`Path '${path}' cannot be empty`); + } + + let directory = pathParts.join("/"); + if (directory) { + directory += "/"; + } + + 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, ""); + + let newName = path; + do { + currentCount++; + newName = `${directory}${stem} (${currentCount})${extension}`; + } while (await this.fs.exists(newName)); + + return newName; + } +} diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts new file mode 100644 index 00000000..d5d1eedc --- /dev/null +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -0,0 +1,35 @@ +import type { RelativePath } from "../persistence/database"; + +import type { TextWithCursors } from "reconcile-text"; + +export interface FileSystemOperations { + // List all files that should be synced. + listAllFiles: () => 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; + + // 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; + + // 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; + + // 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; +} diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts new file mode 100644 index 00000000..2b1f908a --- /dev/null +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -0,0 +1,137 @@ +import type { RelativePath } from "../persistence/database"; +import type { FileSystemOperations } from "./filesystem-operations"; +import type { Logger } from "../tracing/logger"; +import { Locks } from "../utils/locks"; +import { FileNotFoundError } from "./file-not-found-error"; +import type { TextWithCursors } from "reconcile-text"; + +/** + * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` + * if the accessed file doesn't exist. It also ensures that there's at most a + * single request in-flight for any one file through the use of locks. + */ +export class SafeFileSystemOperations implements FileSystemOperations { + private readonly locks: Locks; + + public constructor( + private readonly fs: FileSystemOperations, + private readonly logger: Logger + ) { + this.locks = new Locks(logger); + } + + public async listAllFiles(): Promise { + this.logger.debug("Listing all files"); + const result = await this.fs.listAllFiles(); + 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 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 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 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 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" + ); + } + + /** + * 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}` + ); + } + + 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}` + ); + } + } + } +} diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts new file mode 100644 index 00000000..a73f63dd --- /dev/null +++ b/frontend/sync-client/src/index.ts @@ -0,0 +1,42 @@ +import { logToConsole } from "./debugging/log-to-console"; +import { slowFetchFactory } from "./debugging/slow-fetch-factory"; +import { slowWebSocketFactory } from "./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"; + +export { + 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"; +export { rateLimit } from "./utils/rate-limit"; +export type { RelativePath, StoredDatabase } from "./persistence/database"; +export type { FileSystemOperations } from "./file-operations/filesystem-operations"; +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 { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; +export { DocumentSyncStatus } from "./types/document-sync-status"; +export { SyncClient } from "./sync-client"; + +export const debugging = { + slowFetchFactory, + slowWebSocketFactory, + logToConsole +}; + +export const utils = { + getRandomColor, + positionToLineAndColumn, + lineAndColumnToPosition +}; diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts new file mode 100644 index 00000000..9425c629 --- /dev/null +++ b/frontend/sync-client/src/persistence/database.ts @@ -0,0 +1,360 @@ +import type { Logger } from "../tracing/logger"; +import { EMPTY_HASH } from "../utils/hash"; +import { CoveredValues } from "../utils/min-covered"; + +export type VaultUpdateId = number; +export type DocumentId = string; +export type RelativePath = string; + +export interface DocumentMetadata { + parentVersionId: VaultUpdateId; + hash: string; + remoteRelativePath?: RelativePath; +} + +export interface StoredDocumentMetadata { + relativePath: RelativePath; + documentId: DocumentId; + parentVersionId: VaultUpdateId; + remoteRelativePath?: RelativePath; + hash: string; +} + +export interface StoredDatabase { + documents: StoredDocumentMetadata[]; + lastSeenUpdateId: VaultUpdateId | undefined; + hasInitialSyncCompleted: boolean; +} + +/** + * Represents a document in the database. + * + * It is mutable and its content should always represent the latest + * 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; +} + +export class Database { + 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 ??= {}; + + 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`); + + 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}` + ); + } + + public get length(): number { + return this.documents.length; + } + + 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) ?? []) + ]) + ); + + return Array.from(paths.values()).map((records) => { + records.sort( + (a, b) => b.parallelVersion - a.parallelVersion // descending + ); + + 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]; + }); + } + + 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"); + } + + toUpdate.metadata = metadata; + + this.save(); + } + + public removeDocumentPromise(promise: Promise): void { + const entry = this.documents.find(({ updates }) => + updates.includes(promise) + ); + + if (entry === undefined) { + // This method should be idempotent and tolerant of + // stragglers calling it after the databse has been reset. + return; + } + + entry.updates = entry.updates.filter((update) => update !== promise); + // No need to save as Promises don't get serialized + } + + public removeDocument(find: DocumentRecord): void { + this.documents = this.documents.filter((document) => document !== find); + this.save(); + } + + 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 async getResolvedDocumentByRelativePath( + relativePath: RelativePath, + promise: Promise + ): Promise { + const entry = this.getLatestDocumentByRelativePath(relativePath); + + if (entry === undefined) { + throw new Error( + `Document not found by relative path: ${relativePath}, ${JSON.stringify( + this.documents, + null, + 2 + )}` + ); + } + + const currentPromises = entry.updates; + entry.updates = [...currentPromises, promise]; + await Promise.all(currentPromises); + + return entry; + } + + public createNewPendingDocument( + documentId: DocumentId, + relativePath: RelativePath, + promise: Promise + ): DocumentRecord { + const previousEntry = + this.getLatestDocumentByRelativePath(relativePath); + + const entry = { + relativePath, + documentId, + metadata: undefined, + isDeleted: false, + updates: [promise], + parallelVersion: + previousEntry?.parallelVersion === undefined + ? 0 + : previousEntry.parallelVersion + 1 + }; + + this.documents.push(entry); + this.save(); + + return entry; + } + + 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 + }; + + this.documents.push(entry); + this.save(); + + return entry; + } + + public getDocumentByDocumentId( + find: DocumentId + ): DocumentRecord | undefined { + return this.documents.find(({ documentId }) => documentId === find); + } + + public move( + oldRelativePath: RelativePath, + newRelativePath: RelativePath + ): void { + const oldDocument = + this.getLatestDocumentByRelativePath(oldRelativePath); + + if (oldDocument === undefined) { + return; + } + + const newDocument = + this.getLatestDocumentByRelativePath(newRelativePath); + if (newDocument?.isDeleted === false) { + throw new Error( + `Document already exists at new location: ${newRelativePath}` + ); + } + + 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; + + this.save(); + } + + 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 getHasInitialSyncCompleted(): boolean { + return this.hasInitialSyncCompleted; + } + + public setHasInitialSyncCompleted(value: boolean): void { + this.hasInitialSyncCompleted = value; + this.save(); + } + + public getLastSeenUpdateId(): VaultUpdateId { + return this.lastSeenUpdateIds.min; + } + + public addSeenUpdateId(value: number): void { + const previousMin = this.lastSeenUpdateIds.min; + this.lastSeenUpdateIds.add(value); + if (previousMin !== this.lastSeenUpdateIds.min) { + this.save(); + } + } + + public setLastSeenUpdateId(value: number): void { + this.lastSeenUpdateIds.min = value; + this.save(); + } + + 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(); + } + + 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}`); + }); + } + + private ensureConsistency(): void { + const idToPath = new Map(); + + this.resolvedDocuments.forEach(({ relativePath, documentId }) => { + idToPath.set(documentId, [ + ...(idToPath.get(documentId) ?? []), + relativePath + ]); + }); + + 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("; ") + ); + } + } +} diff --git a/frontend/sync-client/src/persistence/persistence.ts b/frontend/sync-client/src/persistence/persistence.ts new file mode 100644 index 00000000..706ae6ff --- /dev/null +++ b/frontend/sync-client/src/persistence/persistence.ts @@ -0,0 +1,4 @@ +export interface PersistenceProvider { + load: () => Promise; + save: (data: T) => Promise; +} diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts new file mode 100644 index 00000000..b0aff937 --- /dev/null +++ b/frontend/sync-client/src/persistence/settings.ts @@ -0,0 +1,84 @@ +import type { Logger } from "../tracing/logger"; + +export interface SyncSettings { + remoteUri: string; + token: string; + vaultName: string; + syncConcurrency: number; + isSyncEnabled: boolean; + maxFileSizeMB: number; + ignorePatterns: string[]; + webSocketRetryIntervalMs: number; +} + +export const DEFAULT_SETTINGS: SyncSettings = { + remoteUri: "", + token: "", + vaultName: "default", + syncConcurrency: 1, + isSyncEnabled: false, + maxFileSizeMB: 10, + ignorePatterns: [], + webSocketRetryIntervalMs: 3500 +}; + +export class Settings { + private settings: SyncSettings; + + private readonly onSettingsChangeHandlers: (( + newSettings: SyncSettings, + oldSettings: SyncSettings + ) => unknown)[] = []; + + 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)}` + ); + } + + 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 { + this.logger.debug(`Setting '${key}' to '${value}'`); + await this.setSettings({ + [key]: value + }); + } + + public async setSettings(value: Partial): Promise { + const oldSettings = this.settings; + this.settings = { + ...this.settings, + ...value + }; + + this.onSettingsChangeHandlers.forEach((handler) => { + handler(this.settings, oldSettings); + }); + await this.save(); + } + + private async save(): Promise { + await this.saveData(this.settings); + } +} diff --git a/frontend/sync-client/src/services/connection-status.ts b/frontend/sync-client/src/services/connection-status.ts new file mode 100644 index 00000000..18f53a0d --- /dev/null +++ b/frontend/sync-client/src/services/connection-status.ts @@ -0,0 +1,98 @@ +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/sync-reset-error.ts b/frontend/sync-client/src/services/sync-reset-error.ts new file mode 100644 index 00000000..5e27dfb6 --- /dev/null +++ b/frontend/sync-client/src/services/sync-reset-error.ts @@ -0,0 +1,6 @@ +export class SyncResetError extends Error { + public constructor() { + super("Sync was reset"); + this.name = "SyncResetError"; + } +} diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts new file mode 100644 index 00000000..8ce9c56a --- /dev/null +++ b/frontend/sync-client/src/services/sync-service.ts @@ -0,0 +1,325 @@ +import type { + 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 { sleep } from "../utils/sleep"; +import { SyncResetError } from "./sync-reset-error"; +import type { SerializedError } from "./types/SerializedError"; +import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; +import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; +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; +} + +export class SyncService { + private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; + 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); + + this.client = this.connectionStatus.getFetchImplementation( + 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}`; + } + + return result; + } + + 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)]) + ); + + const response = await this.client(this.getUrl("/documents"), { + method: "POST", + body: formData, + headers: this.getDefaultHeaders() + }); + + const result: SerializedError | DocumentVersionWithoutContent = + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; + + if ("errorType" in result) { + throw new Error( + `Failed to create document: ${SyncService.formatError(result)}` + ); + } + + this.logger.debug( + `Created document ${JSON.stringify(result)} with id ${ + result.documentId + }` + ); + + return result; + }); + } + + 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)]) + ); + + const response = await this.client( + this.getUrl(`/documents/${documentId}`), + { + method: "PUT", + body: formData, + headers: this.getDefaultHeaders() + } + ); + + const result: SerializedError | DocumentUpdateResponse = + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentUpdateResponse; + + if ("errorType" in result) { + throw new Error( + `Failed to update document: ${SyncService.formatError(result)}` + ); + } + + this.logger.debug( + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId + }}` + ); + + return result; + }); + } + + 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: SerializedError | DocumentVersionWithoutContent = + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | DocumentVersionWithoutContent; + + if ("errorType" in result) { + throw new Error( + `Failed to delete document: ${SyncService.formatError(result)}` + ); + } + + this.logger.debug( + `Deleted document ${relativePath} with id ${documentId}` + ); + + return result; + }); + } + + public async get({ + documentId + }: { + documentId: DocumentId; + }): Promise { + return this.withRetries(async () => { + const response = await this.client( + this.getUrl(`/documents/${documentId}`), + { + headers: this.getDefaultHeaders() + } + ); + + const result: SerializedError | DocumentVersion = + (await response.json()) as SerializedError | DocumentVersion; // 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( + `Get document ${result.relativePath} with id ${result.documentId}` + ); + + return result; + }); + } + + 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() + }); + + const result: SerializedError | FetchLatestDocumentsResponse = + (await response.json()) as // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + | SerializedError + | FetchLatestDocumentsResponse; + + if ("errorType" in result) { + throw new Error( + `Failed to get documents: ${SyncService.formatError(result)}` + ); + } + + this.logger.debug( + `Got ${result.latestDocuments.length} document metadata` + ); + + return result; + }); + } + + 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 + + if ("errorType" in result) { + throw new Error( + `Failed to ping server: ${SyncService.formatError(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}` + }; + } + } + + private getUrl(path: string): string { + const { vaultName, remoteUri } = this.settings.getSettings(); + const safeRemoteUri = remoteUri.replace(/\/+$/, ""); + return `${safeRemoteUri}/vaults/${vaultName}${path}`; + } + + private getDefaultHeaders(): Record { + return { + "device-id": this.deviceId, + authorization: `Bearer ${this.settings.getSettings().token}` + }; + } + + 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; + } + + this.logger.error( + `Failed network call (${e}), retrying in ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms` + ); + await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS); + } + } + } +} diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts new file mode 100644 index 00000000..8222bfb0 --- /dev/null +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentWithCursors } from "./DocumentWithCursors"; + +export interface ClientCursors { + 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 new file mode 100644 index 00000000..d4bd376b --- /dev/null +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -0,0 +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[]; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts new file mode 100644 index 00000000..ca940e3e --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentWithCursors } from "./DocumentWithCursors"; + +export interface CursorPositionFromClient { + documentsWithCursors: DocumentWithCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts new file mode 100644 index 00000000..2556b748 --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientCursors } from "./ClientCursors"; + +export interface CursorPositionFromServer { + clients: ClientCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts new file mode 100644 index 00000000..5bc2542e --- /dev/null +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -0,0 +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; +} diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts new file mode 100644 index 00000000..9edb09ed --- /dev/null +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -0,0 +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; +} diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts new file mode 100644 index 00000000..f0ed7abf --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersion } from "./DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to an update document request. + */ +export type DocumentUpdateResponse = + | ({ 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 new file mode 100644 index 00000000..2076d296 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -0,0 +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; +} diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts new file mode 100644 index 00000000..cb23f6a5 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -0,0 +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; +} diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts new file mode 100644 index 00000000..dae654c7 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CursorSpan } from "./CursorSpan"; + +export interface DocumentWithCursors { + 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 new file mode 100644 index 00000000..67c19b2d --- /dev/null +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +/** + * Response to a fetch latest documents request. + */ +export interface FetchLatestDocumentsResponse { + 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 new file mode 100644 index 00000000..b0d993f2 --- /dev/null +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 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; +} diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts new file mode 100644 index 00000000..c0979c5a --- /dev/null +++ b/frontend/sync-client/src/services/types/SerializedError.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 SerializedError { + 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 new file mode 100644 index 00000000..bc3d54e5 --- /dev/null +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.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 UpdateDocumentVersion { + parent_version_id: bigint; + relative_path: string; + content: number[]; +} diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts new file mode 100644 index 00000000..e7de2cf3 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.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. +import type { CursorPositionFromClient } from "./CursorPositionFromClient"; +import type { WebSocketHandshake } from "./WebSocketHandshake"; + +export type WebSocketClientMessage = + | ({ 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 new file mode 100644 index 00000000..068b3505 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.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 WebSocketHandshake { + 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 new file mode 100644 index 00000000..8ebf8911 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.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. +import type { CursorPositionFromServer } from "./CursorPositionFromServer"; +import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; + +export type WebSocketServerMessage = + | ({ 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 new file mode 100644 index 00000000..ad50c25d --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.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. +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + +export interface WebSocketVaultUpdate { + documents: DocumentVersionWithoutContent[]; + isInitialSync: boolean; +} diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts new file mode 100644 index 00000000..a30774f4 --- /dev/null +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -0,0 +1,199 @@ +import type { Database } from "../persistence/database"; +import type { Logger } from "../tracing/logger"; +import type { Settings, SyncSettings } 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"; + +export class WebSocketManager { + private readonly webSocketStatusChangeListeners: (() => unknown)[] = []; + private readonly remoteCursorsUpdateListeners: (( + cursors: ClientCursors[] + ) => unknown)[] = []; + + private webSocket: WebSocket | undefined; + + private isStopped = true; + private _isFirstSyncCompleted = false; + + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; + + 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; + } + } + + settings.addOnSettingsChangeListener((newSettings, oldSettings) => { + if ( + newSettings.remoteUri !== oldSettings.remoteUri || + newSettings.vaultName !== oldSettings.vaultName || + newSettings.token !== oldSettings.token + ) { + this.initializeWebSocket(newSettings); + } + }); + } + + public get isWebSocketConnected(): boolean { + return ( + this.webSocket?.readyState === + this.webSocketFactoryImplementation.OPEN + ); + } + + public get isFirstSyncCompleted(): boolean { + return this._isFirstSyncCompleted; + } + + public addWebSocketStatusChangeListener(listener: () => unknown): void { + this.webSocketStatusChangeListeners.push(listener); + } + + public addRemoteCursorsUpdateListener( + listener: (cursors: ClientCursors[]) => unknown + ): void { + this.remoteCursorsUpdateListeners.push(listener); + } + + public start(): void { + this.isStopped = false; + this._isFirstSyncCompleted = false; + this.initializeWebSocket(this.settings.getSettings()); + } + + public stop(): void { + this.isStopped = true; + this.webSocket?.close(1000, "WebSocketManager has been stopped"); + } + + 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)}` + ); + } + + private initializeWebSocket(settings: SyncSettings): void { + if (this.isStopped) { + return; + } + + try { + this.webSocket?.close(); + } catch (e) { + this.logger.warn(`Failed to close WebSocket: ${e}`); + } + + const wsUri = new URL(settings.remoteUri); + wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; + wsUri.pathname = `/vaults/${settings.vaultName}/ws`; + + this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); + + this.webSocket = new this.webSocketFactoryImplementation(wsUri); + + // 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()); + + const message: WebSocketClientMessage = { + type: "handshake", + deviceId: this.deviceId, + token: settings.token, + lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() + }; + this.webSocket?.send(JSON.stringify(message)); + }; + + 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); + }; + + 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()); + + if (!this.isStopped) { + setTimeout(() => { + this.initializeWebSocket(this.settings.getSettings()); + }, this.settings.getSettings().webSocketRetryIntervalMs); + } + }; + } + + private async handleWebSocketMessage( + message: WebSocketServerMessage + ): Promise { + if (message.type === "vaultUpdate") { + try { + await Promise.all( + message.documents.map(async (document) => + this.syncer.syncRemotelyUpdatedFile(document) + ) + ); + + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) + ); + } + + 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)}` + ); + } + } +} diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts new file mode 100644 index 00000000..78beb910 --- /dev/null +++ b/frontend/sync-client/src/sync-client.ts @@ -0,0 +1,337 @@ +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 type { RelativePath, StoredDatabase } from "./persistence/database"; +import { Database } from "./persistence/database"; +import type { SyncSettings } from "./persistence/settings"; +import { 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 { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; +import { rateLimit } from "./utils/rate-limit"; +import type { NetworkConnectionStatus } from "./types/network-connection-status"; +import { DocumentSyncStatus } from "./types/document-sync-status"; +import { WebSocketManager } from "./services/websocket-manager"; +import { createClientId } from "./utils/create-client-id"; +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"; + +export class SyncClient { + private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000; + private hasStartedOfflineSync = false; + private hasFinishedOfflineSync = false; + + // 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(); + } + + if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { + if (newSettings.isSyncEnabled) { + await this.start(); + } else { + this.stop(); + } + } + } + ); + } + + public get logger(): Logger { + return this._logger; + } + + public get documentCount(): number { + return this.database.length; + } + + 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 deviceId = createClientId(); + + logger.info(`Initialising SyncClient with client id ${deviceId}`); + + const history = new SyncHistory(logger); + + let state = (await persistence.load()) ?? { + settings: undefined, + database: undefined + }; + + const rateLimitedSave = rateLimit( + persistence.save, + SyncClient.MINIMUM_SAVE_INTERVAL_MS + ); + + const database = new Database( + logger, + state.database, + async (data): Promise => { + state = { ...state, database: data }; + await rateLimitedSave(state); + } + ); + + const settings = new Settings( + logger, + state.settings, + async (data): Promise => { + state = { ...state, settings: data }; + await rateLimitedSave(state); + } + ); + + const connectionStatus = new ConnectionStatus(settings, logger); + const syncService = new SyncService( + deviceId, + connectionStatus, + settings, + logger, + fetch + ); + + const fileOperations = new FileOperations( + logger, + database, + fs, + nativeLineEndings + ); + + const unrestrictedSyncer = new UnrestrictedSyncer( + logger, + database, + settings, + syncService, + fileOperations, + history + ); + + const syncer = new Syncer( + logger, + database, + settings, + syncService, + fileOperations, + unrestrictedSyncer + ); + + const webSocketManager = new WebSocketManager( + deviceId, + logger, + database, + settings, + syncer, + webSocket + ); + + 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 + ); + + logger.info("SyncClient initialised"); + + return client; + } + + public async checkConnection(): Promise { + const server = await this.syncService.checkConnection(); + return { + isSuccessful: server.isSuccessful, + serverMessage: server.message, + isWebSocketConnected: this.webSocketManager.isWebSocketConnected + }; + } + + public getHistoryEntries(): readonly HistoryEntry[] { + return this.history.entries; + } + + public addSyncHistoryUpdateListener( + listener: (stats: HistoryStats) => unknown + ): void { + this.history.addSyncHistoryUpdateListener(listener); + } + + public async start(): Promise { + if (!this.hasStartedOfflineSync) { + await this.syncer.scheduleSyncForOfflineChanges(); + this.hasStartedOfflineSync = true; + } + + this.hasFinishedOfflineSync = true; + this.webSocketManager.start(); + } + + public stop(): void { + this.hasFinishedOfflineSync = false; + this.webSocketManager.stop(); + } + + public async waitAndStop(): Promise { + this.stop(); + await this.syncer.waitUntilFinished(); + } + + /// 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(); + } + + public getSettings(): SyncSettings { + return this.settings.getSettings(); + } + + public async setSetting( + key: T, + value: SyncSettings[T] + ): Promise { + await this.settings.setSetting(key, value); + } + + public async setSettings(value: Partial): Promise { + await this.settings.setSettings(value); + } + + public addOnSettingsChangeListener( + handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown + ): void { + this.settings.addOnSettingsChangeListener(handler); + } + + public addRemainingSyncOperationsListener( + listener: (remainingOperations: number) => unknown + ): void { + this.syncer.addRemainingOperationsListener(listener); + } + + public addWebSocketStatusChangeListener(listener: () => unknown): void { + this.webSocketManager.addWebSocketStatusChangeListener(listener); + } + + public async syncLocallyCreatedFile( + relativePath: RelativePath + ): Promise { + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyCreatedFile(relativePath); + } + + public async syncLocallyDeletedFile( + relativePath: RelativePath + ): Promise { + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyDeletedFile(relativePath); + } + + public async syncLocallyUpdatedFile({ + oldPath, + relativePath + }: { + oldPath?: RelativePath; + relativePath: RelativePath; + }): Promise { + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyUpdatedFile({ + oldPath, + relativePath + }); + } + + public getDocumentSyncingStatus( + relativePath: RelativePath + ): DocumentSyncStatus { + if (!this.settings.getSettings().isSyncEnabled) { + return DocumentSyncStatus.SYNCING_IS_DISABLED; + } + + if ( + !this.webSocketManager.isFirstSyncCompleted || + !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 { + await this.cursorTracker.sendLocalCursorsToServer(documentToCursors); + } + + public addRemoteCursorsUpdateListener( + listener: (cursors: MaybeOutdatedClientCursors[]) => unknown + ): void { + this.cursorTracker.addRemoteCursorsUpdateListener(listener); + } +} diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts new file mode 100644 index 00000000..17f166c4 --- /dev/null +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -0,0 +1,253 @@ +import type { FileOperations } from "../file-operations/file-operations"; +import type { Database, RelativePath } from "../persistence/database"; +import type { ClientCursors } from "../services/types/ClientCursors"; +import type { CursorSpan } from "../services/types/CursorSpan"; +import type { DocumentWithCursors } from "../services/types/DocumentWithCursors"; +import type { WebSocketManager } from "../services/websocket-manager"; +import type { MaybeOutdatedClientCursors } from "../types/maybe-outdated-client-cursors"; +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"; + +// 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(); + + private knownRemoteCursors: (ClientCursors & { + upToDateness: DocumentUpToDateness; + })[] = []; + + private lastLocalCursorState: DocumentWithCursors[] = []; + private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = + []; + + 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) + ); + + 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.knownRemoteCursors = updatedKnownRemoteCursors; + }); + } + ); + + 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); + } + } + }) + ); + } + + /// 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[] = []; + + for (const [relativePath, cursors] of Object.entries( + documentToCursors + )) { + const record = + this.database.getLatestDocumentByRelativePath(relativePath); + + if (!record) { + continue; // Let's wait for the file to be created before sending cursors + } + + if (!record.metadata) { + continue; // this is a new document, no need to sync the cursors + } + + 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.lastLocalCursorState) === + JSON.stringify(documentsWithCursors) + ) { + // Caching step to avoid reading the edited files all the time + return; + } + this.lastLocalCursorState = 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; + } + } + + if ( + JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === + JSON.stringify(documentsWithCursors) + ) { + return; + } + + this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; + + this.webSocketManager.updateLocalCursors({ documentsWithCursors }); + } + + // 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()) + ); + }); + } + + private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] { + const result: MaybeOutdatedClientCursors[] = []; + const included = new Set(); + + const relevantCursors = []; + for (const clientCursors of [...this.knownRemoteCursors].reverse()) { + if (included.has(clientCursors.deviceId)) { + continue; + } + + if (clientCursors.upToDateness == DocumentUpToDateness.Later) { + continue; + } + + result.push({ + ...clientCursors, + isOutdated: + clientCursors.upToDateness == DocumentUpToDateness.Prior + }); + + included.add(clientCursors.deviceId); + relevantCursors.unshift(clientCursors); // to reverse order back to normal + } + + this.knownRemoteCursors = relevantCursors; + + return result; + } + + // 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)); + } + + if ( + results.every((result) => result === DocumentUpToDateness.UpToDate) + ) { + return DocumentUpToDateness.UpToDate; + } + + if ( + results.every( + (result) => + result === DocumentUpToDateness.UpToDate || + result === DocumentUpToDateness.Prior + ) + ) { + return DocumentUpToDateness.Prior; + } + + return DocumentUpToDateness.Later; + } + + private async getDocumentUpToDateness( + document: DocumentWithCursors + ): Promise { + const record = this.database.getLatestDocumentByRelativePath( + document.relative_path + ); + + if (!record) { + // the document of the cursor must be from the future + 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; + } + + 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 new file mode 100644 index 00000000..8a7af66c --- /dev/null +++ b/frontend/sync-client/src/sync-operations/file-change-notifier.ts @@ -0,0 +1,15 @@ +import type { RelativePath } from "../persistence/database"; + +export class FileChangeNotifier { + private readonly listeners: ((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)); + } +} diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts new file mode 100644 index 00000000..186b9a9b --- /dev/null +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -0,0 +1,459 @@ +import type { + Database, + DocumentId, + DocumentRecord, + RelativePath +} from "../persistence/database"; +import type { SyncService } from "../services/sync-service"; +import type { Logger } from "../tracing/logger"; +import PQueue from "p-queue"; +import { hash } from "../utils/hash"; +import { v4 as uuidv4 } from "uuid"; +import type { Settings } from "../persistence/settings"; +import type { FileOperations } from "../file-operations/file-operations"; +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 type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; + +export class Syncer { + private readonly remoteDocumentsLock: Locks; + private readonly remainingOperationsListeners: (( + remainingOperations: number + ) => unknown)[] = []; + private readonly syncQueue: PQueue; + + private runningScheduleSyncForOfflineChanges: Promise | undefined; + + 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 + }); + + this.remoteDocumentsLock = new Locks(this.logger); + + settings.addOnSettingsChangeListener((newSettings, oldSettings) => { + if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { + this.syncQueue.concurrency = newSettings.syncConcurrency; + } + }); + + this.syncQueue.on("active", () => { + this.remainingOperationsListeners.forEach((listener) => { + listener(this.syncQueue.size); + }); + }); + } + + public addRemainingOperationsListener( + listener: (remainingOperations: number) => unknown + ): void { + this.remainingOperationsListeners.push(listener); + } + + 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; + } + + const [promise, resolve, reject] = createPromise(); + + const id = uuidv4(); + const document = this.database.createNewPendingDocument( + id, + relativePath, + promise + ); + + this.logger.debug( + `Creating new pending document ${relativePath} with id ${id}` + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(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; + } + + // 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); + + const [promise, resolve, reject] = createPromise(); + + const document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document) + ); + + resolve(); + + this.database.removeDocument(document); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } + + 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}` + ); + } + + this.database.move(oldPath, relativePath); + } + } + + let document = + this.database.getLatestDocumentByRelativePath(relativePath); + + 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; + } + + if (document === undefined) { + this.logger.debug( + `Cannot find document ${relativePath} in the database, skipping` + ); + return; + } + + if (document.isDeleted) { + this.logger.debug( + `Document ${relativePath} has been deleted locally, skipping` + ); + return; + } + + const [promise, resolve, reject] = createPromise(); + + document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({ + oldPath, + document + }) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } + + public async scheduleSyncForOfflineChanges(): Promise { + if (this.runningScheduleSyncForOfflineChanges !== undefined) { + this.logger.debug("Uploading local changes is already in progress"); + return this.runningScheduleSyncForOfflineChanges; + } + + 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 waitUntilFinished(): Promise { + await this.runningScheduleSyncForOfflineChanges; + return this.syncQueue.onEmpty(); + } + + public async reset(): Promise { + await this.waitUntilFinished(); + } + + public async syncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent + ): Promise { + let document = this.database.getDocumentByDocumentId( + remoteVersion.documentId + ); + + 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 + ); + + // 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(); + + document = + await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } + + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } + ); + } + + // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` + const [promise, resolve, reject] = createPromise(); + + document = await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } + + private async internalScheduleSyncForOfflineChanges(): Promise { + await this.createFakeDocumentsFromRemoteState(); + + const allLocalFiles = await this.operations.listAllFiles(); + + let locallyPossiblyDeletedFiles: DocumentRecord[] = []; + + for (const document of this.database.resolvedDocuments) { + if ( + !document.isDeleted && + !(await this.operations.exists(document.relativePath)) + ) { + locallyPossiblyDeletedFiles.push(document); + } + } + + 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` + ); + + return this.syncLocallyUpdatedFile({ + 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); + }); + + if (contentHash == undefined) { + // The file was deleted before we had a chance to read it, no need to sync it here + return; + } + + 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 + ); + + 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` + ); + + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyUpdatedFile({ + oldPath: originalFile.relativePath, + relativePath + }); + } + + 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); + }) + ); + + const deletes = Promise.all( + 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); + }) + ); + + await Promise.all([updates, deletes]); + } + + /** + * 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 Promise.all([ + this.operations.listAllFiles(), + 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 new file mode 100644 index 00000000..0d0f45ef --- /dev/null +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -0,0 +1,513 @@ +import type { + Database, + DocumentRecord, + RelativePath +} from "../persistence/database"; + +import type { SyncService } from "../services/sync-service"; +import type { Logger } from "../tracing/logger"; +import type { + 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 type { Settings } from "../persistence/settings"; +import type { FileOperations } from "../file-operations/file-operations"; +import { createPromise } from "../utils/create-promise"; +import { FileNotFoundError } from "../file-operations/file-not-found-error"; +import { SyncResetError } from "../services/sync-reset-error"; +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"; + +export class UnrestrictedSyncer { + 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 + ); + + this.settings.addOnSettingsChangeListener((newSettings) => { + this.ignorePatterns = globsToRegexes( + newSettings.ignorePatterns, + this.logger + ); + }); + } + + 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; + } + + const contentBytes = await this.operations.read( + document.relativePath + ); // this can throw FileNotFoundError + const contentHash = hash(contentBytes); + + const response = await this.syncService.create({ + documentId: document.documentId, + relativePath: document.relativePath, + contentBytes + }); + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + + this.database.addSeenUpdateId(response.vaultUpdateId); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully uploaded locally created file` + }); + }); + } + + public async unrestrictedSyncLocallyDeletedFile( + document: DocumentRecord + ): Promise { + const updateDetails: SyncDeleteDetails = { + type: SyncType.DELETE, + relativePath: document.relativePath + }; + + await this.executeSync(updateDetails, async () => { + const response = await this.syncService.delete({ + documentId: document.documentId, + relativePath: document.relativePath + }); + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: document.relativePath + }, + document + ); + + this.database.addSeenUpdateId(response.vaultUpdateId); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully deleted locally deleted file on the server`, + author: response.userId + }); + }); + } + + 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 + }; + + await this.executeSync(updateDetails, async () => { + const originalRelativePath = document.relativePath; + + if (document.isDeleted || document.metadata === undefined) { + this.logger.debug( + `Document ${document.relativePath} has been already deleted, no need to update it` + ); + return; + } + + const contentBytes = await this.operations.read( + document.relativePath + ); // this can throw FileNotFoundError + let contentHash = hash(contentBytes); + + const areThereLocalChanges = !( + document.metadata.hash === contentHash && oldPath === undefined + ); + + let response: DocumentVersion | DocumentUpdateResponse | undefined = + 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; + } + + response = await this.syncService.get({ + documentId: document.documentId + }); + } + + // `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; + } + + 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; + } + + 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 + }); + + this.database.delete(document.relativePath); + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: response.relativePath + }, + document + ); + + await this.operations.delete(document.relativePath); + + this.database.addSeenUpdateId(response.vaultUpdateId); + + return; + } + + let actualPath = document.relativePath; + + 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 + } + + if (!("type" in response) || response.type === "MergingUpdate") { + const responseBytes = deserialize(response.contentBase64); + contentHash = hash(responseBytes); + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + + await this.operations.write( + actualPath, + contentBytes, + 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 + ); + } + + this.database.addSeenUpdateId(response.vaultUpdateId); + + const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = + oldPath !== undefined || + response.relativePath != originalRelativePath + ? { + type: SyncType.MOVE, + relativePath: response.relativePath, + movedFrom: originalRelativePath + } + : { + type: SyncType.UPDATE, + relativePath: response.relativePath + }; + + 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 + }); + } + }); + } + + public async unrestrictedSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent, + document?: DocumentRecord + ): Promise { + const updateDetails: SyncCreateDetails = { + type: SyncType.CREATE, + relativePath: remoteVersion.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` + ); + + return; + } + + 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; + } + + // Don't download oversized files + const historyEntryForSkippedOversizedFile = + this.getHistoryEntryForSkippedOversizedFile( + remoteVersion.contentSize, + remoteVersion.relativePath + ); + if (historyEntryForSkippedOversizedFile !== undefined) { + this.history.addHistoryEntry( + historyEntryForSkippedOversizedFile + ); + return; + } + + const content = ( + await this.syncService.get({ + documentId: remoteVersion.documentId + }) + ).contentBase64; + + // 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; + } + + const contentBytes = deserialize(content); + + await this.operations.ensureClearPath(remoteVersion.relativePath); + + const [promise, resolve] = createPromise(); + this.database.updateDocumentMetadata( + { + parentVersionId: remoteVersion.vaultUpdateId, + hash: hash(contentBytes), + remoteRelativePath: remoteVersion.relativePath + }, + this.database.createNewPendingDocument( + remoteVersion.documentId, + remoteVersion.relativePath, + promise + ) + ); + + await this.operations.create( + remoteVersion.relativePath, + contentBytes + ); + + resolve(); + this.database.removeDocumentPromise(promise); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully downloaded remote file which hadn't existed locally`, + author: remoteVersion.userId + }); + }); + } + + 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 + } + } + + 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; + } + } + + 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; + } + } + } + + 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` + }; + } + } +} diff --git a/frontend/sync-client/src/tracing/logger.ts b/frontend/sync-client/src/tracing/logger.ts new file mode 100644 index 00000000..cf39e4de --- /dev/null +++ b/frontend/sync-client/src/tracing/logger.ts @@ -0,0 +1,79 @@ +export enum LogLevel { + DEBUG = "DEBUG", + INFO = "INFO", + WARNING = "WARNING", + ERROR = "ERROR" +} + +const LOG_LEVEL_ORDER = { + [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 + ) {} +} + +export class Logger { + private static readonly MAX_MESSAGES = 100000; + private readonly messages: LogLine[] = []; + private readonly onMessageListeners: ((message: LogLine) => unknown)[] = []; + + public constructor( + ...onMessageListeners: ((message: LogLine) => unknown)[] + ) { + this.onMessageListeners = onMessageListeners; + } + + public debug(message: string): void { + this.pushMessage(message, LogLevel.DEBUG); + } + + public info(message: string): void { + this.pushMessage(message, LogLevel.INFO); + } + + public warn(message: string): void { + this.pushMessage(message, LogLevel.WARNING); + } + + 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 addOnMessageListener(listener: (message: LogLine) => unknown): void { + this.onMessageListeners.push(listener); + } + + 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); + + while (this.messages.length > Logger.MAX_MESSAGES) { + this.messages.shift(); + } + + this.onMessageListeners.forEach((listener) => { + listener(logLine); + }); + } +} diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts new file mode 100644 index 00000000..6890688b --- /dev/null +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -0,0 +1,174 @@ +import type { RelativePath } from "../persistence/database"; +import type { Logger } from "./logger"; + +export interface SyncCreateDetails { + type: SyncType.CREATE; + relativePath: RelativePath; +} + +export interface SyncUpdateDetails { + type: SyncType.UPDATE; + relativePath: RelativePath; +} + +export interface SyncMovedDetails { + type: SyncType.MOVE; + relativePath: RelativePath; + movedFrom: RelativePath; +} + +export interface SyncDeleteDetails { + type: SyncType.DELETE; + relativePath: RelativePath; +} + +export interface SyncSkippedDetails { + type: SyncType.SKIPPED; + relativePath: RelativePath; +} + +export type SyncDetails = + | SyncCreateDetails + | SyncUpdateDetails + | SyncDeleteDetails + | SyncMovedDetails + | SyncSkippedDetails; + +export interface CommonHistoryEntry { + status: SyncStatus; + message: string; + details: SyncDetails; + author?: string; +} + +export enum SyncType { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", + MOVE = "MOVE", + SKIPPED = "SKIPPED" +} + +export enum SyncStatus { + SUCCESS = "SUCCESS", + ERROR = "ERROR", + SKIPPED = "SKIPPED" +} + +export type HistoryEntry = CommonHistoryEntry & { timestamp: Date }; + +export interface HistoryStats { + success: number; + error: number; +} + +export class SyncHistory { + private static readonly MAX_ENTRIES = 5000; + private static readonly TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS = 60; + + private _entries: HistoryEntry[] = []; + + private readonly syncHistoryUpdateListeners: (( + status: HistoryStats + ) => unknown)[] = []; + + private status: HistoryStats = { + success: 0, + error: 0 + }; + + public constructor(private readonly logger: Logger) {} + + 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: new Date() + }; + + 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); + + if (this._entries.length > SyncHistory.MAX_ENTRIES) { + this._entries.pop(); + } + + this.updateSuccessCount(historyEntry); + } + + public addSyncHistoryUpdateListener( + listener: (stats: HistoryStats) => unknown + ): void { + this.syncHistoryUpdateListeners.push(listener); + listener({ ...this.status }); + } + + public reset(): void { + this._entries.length = 0; + this.status = { + success: 0, + error: 0 + }; + this.syncHistoryUpdateListeners.forEach((listener) => { + listener(this.status); + }); + } + + private findSimilarRecentUpdateEntry( + entry: HistoryEntry + ): HistoryEntry | undefined { + if (entry.details.type !== SyncType.UPDATE) { + return; + } + + 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); + }); + } +} diff --git a/frontend/sync-client/src/types/document-sync-status.ts b/frontend/sync-client/src/types/document-sync-status.ts new file mode 100644 index 00000000..07a0e801 --- /dev/null +++ b/frontend/sync-client/src/types/document-sync-status.ts @@ -0,0 +1,5 @@ +export enum DocumentSyncStatus { + 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 new file mode 100644 index 00000000..2f93f9b4 --- /dev/null +++ b/frontend/sync-client/src/types/document-up-to-dateness.ts @@ -0,0 +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. +} diff --git a/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts b/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts new file mode 100644 index 00000000..e062f84e --- /dev/null +++ b/frontend/sync-client/src/types/maybe-outdated-client-cursors.ts @@ -0,0 +1,5 @@ +import type { ClientCursors } from "../services/types/ClientCursors"; + +export interface MaybeOutdatedClientCursors extends ClientCursors { + isOutdated: boolean; +} diff --git a/frontend/sync-client/src/types/network-connection-status.ts b/frontend/sync-client/src/types/network-connection-status.ts new file mode 100644 index 00000000..fb93f5f5 --- /dev/null +++ b/frontend/sync-client/src/types/network-connection-status.ts @@ -0,0 +1,5 @@ +export interface NetworkConnectionStatus { + 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 new file mode 100644 index 00000000..502dca03 --- /dev/null +++ b/frontend/sync-client/src/utils/assert-set-contains-exactly.ts @@ -0,0 +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(", ")}` + ); +} diff --git a/frontend/sync-client/src/utils/create-client-id.ts b/frontend/sync-client/src/utils/create-client-id.ts new file mode 100644 index 00000000..60143b75 --- /dev/null +++ b/frontend/sync-client/src/utils/create-client-id.ts @@ -0,0 +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 + + 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})`; +} diff --git a/frontend/sync-client/src/utils/create-promise.ts b/frontend/sync-client/src/utils/create-promise.ts new file mode 100644 index 00000000..542a4013 --- /dev/null +++ b/frontend/sync-client/src/utils/create-promise.ts @@ -0,0 +1,25 @@ +type ResolveFunction = undefined extends T + ? (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 +] { + 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_)) + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return [creationPromise, resolve!, reject!]; +} diff --git a/frontend/sync-client/src/utils/deserialize.ts b/frontend/sync-client/src/utils/deserialize.ts new file mode 100644 index 00000000..4255479f --- /dev/null +++ b/frontend/sync-client/src/utils/deserialize.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..10545f2c --- /dev/null +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -0,0 +1,14 @@ +import type { DocumentRecord } from "../persistence/database"; +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[] +): DocumentRecord | undefined { + if (contentHash === EMPTY_HASH) { + return undefined; + } + + 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 new file mode 100644 index 00000000..543b943e --- /dev/null +++ b/frontend/sync-client/src/utils/get-random-color.ts @@ -0,0 +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))})`; +} diff --git a/frontend/sync-client/src/utils/globs-to-regexes.test.ts b/frontend/sync-client/src/utils/globs-to-regexes.test.ts new file mode 100644 index 00000000..3e986ca4 --- /dev/null +++ b/frontend/sync-client/src/utils/globs-to-regexes.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { Logger } from "../tracing/logger"; +import { globsToRegexes } from "./globs-to-regexes"; + +describe("globsToRegexes", () => { + it("basicExample", async () => { + const [regex] = globsToRegexes([".git/**"], new Logger()); + + 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 new file mode 100644 index 00000000..1e8ad775 --- /dev/null +++ b/frontend/sync-client/src/utils/globs-to-regexes.ts @@ -0,0 +1,18 @@ +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); +} diff --git a/frontend/sync-client/src/utils/hash.ts b/frontend/sync-client/src/utils/hash.ts new file mode 100644 index 00000000..cd965db5 --- /dev/null +++ b/frontend/sync-client/src/utils/hash.ts @@ -0,0 +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"); +} + +export const EMPTY_HASH = hash(new Uint8Array(0)); diff --git a/frontend/sync-client/src/utils/is-equal-bytes.test.ts b/frontend/sync-client/src/utils/is-equal-bytes.test.ts new file mode 100644 index 00000000..a887309f --- /dev/null +++ b/frontend/sync-client/src/utils/is-equal-bytes.test.ts @@ -0,0 +1,29 @@ +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 new file mode 100644 index 00000000..d0688d44 --- /dev/null +++ b/frontend/sync-client/src/utils/is-equal-bytes.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000..3f3fffbb --- /dev/null +++ b/frontend/sync-client/src/utils/is-file-type-mergable.test.ts @@ -0,0 +1,42 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { isFileTypeMergable } from "./is-file-type-mergable"; + +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 .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 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 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); + }); +}); diff --git a/frontend/sync-client/src/utils/is-file-type-mergable.ts b/frontend/sync-client/src/utils/is-file-type-mergable.ts new file mode 100644 index 00000000..3b149285 --- /dev/null +++ b/frontend/sync-client/src/utils/is-file-type-mergable.ts @@ -0,0 +1,6 @@ +export function isFileTypeMergable(pathOrFileName: string): boolean { + const parts = pathOrFileName.split("."); + const fileExtension = parts.at(-1) ?? ""; + + return ["md", "txt"].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 new file mode 100644 index 00000000..82d752c9 --- /dev/null +++ b/frontend/sync-client/src/utils/line-and-column-to-position.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from "node:test"; +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 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("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 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)); + }); +}); 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 new file mode 100644 index 00000000..670d8cac --- /dev/null +++ b/frontend/sync-client/src/utils/line-and-column-to-position.ts @@ -0,0 +1,34 @@ +/** + * Converts line and column coordinates to an absolute character position in a text string. + * + * @param line - The zero-based line number + * @param column - The zero-based column number + * @param text - The text string to calculate position in + * @returns The absolute character position (zero-based index) in the text string + * @throws Error if line number is out of range + * @throws Error if column number is out of range + */ +export function lineAndColumnToPosition( + text: string, + line: number, + column: number +): number { + const lines = text.replace("\r", "").split("\n"); + + 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.`); + } + + let position = 0; + for (let i = 0; i < line; i++) { + position += lines[i].length + 1; + } + + position += column; + + return position; +} diff --git a/frontend/sync-client/src/utils/locks.test.ts b/frontend/sync-client/src/utils/locks.test.ts new file mode 100644 index 00000000..5626becc --- /dev/null +++ b/frontend/sync-client/src/utils/locks.test.ts @@ -0,0 +1,228 @@ +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 new file mode 100644 index 00000000..e09da236 --- /dev/null +++ b/frontend/sync-client/src/utils/locks.ts @@ -0,0 +1,142 @@ +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 new file mode 100644 index 00000000..82f792c3 --- /dev/null +++ b/frontend/sync-client/src/utils/min-covered.test.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 00000000..c453ef88 --- /dev/null +++ b/frontend/sync-client/src/utils/min-covered.ts @@ -0,0 +1,56 @@ +/** + * 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 new file mode 100644 index 00000000..bc21b983 --- /dev/null +++ b/frontend/sync-client/src/utils/position-to-line-and-column.test.ts @@ -0,0 +1,66 @@ +import { describe, test } from "node:test"; +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("with carrige returns", () => { + assert.deepStrictEqual(positionToLineAndColumn("a\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("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 new file mode 100644 index 00000000..3df61ded --- /dev/null +++ b/frontend/sync-client/src/utils/position-to-line-and-column.ts @@ -0,0 +1,36 @@ +/** + * Converts a character position in text to line and column numbers. + * + * @param text The text content to analyze + * @param position The character position to convert + * @returns An object containing line and column numbers + * @throws Will throw an error if the position is negative or exceeds the text length + */ +export function positionToLineAndColumn( + text: string, + position: number +): { line: number; column: number } { + if (position < 0) { + throw new Error("Position cannot be negative"); + } + + text = text.replace("\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}` + ); + } + + const textUpToPosition = text.substring(0, position); + const lines = textUpToPosition.split("\n"); + + const line = lines.length - 1; + const column = lines[lines.length - 1].length; + + 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 new file mode 100644 index 00000000..e0b77dc4 --- /dev/null +++ b/frontend/sync-client/src/utils/rate-limit.test.ts @@ -0,0 +1,64 @@ +import { rateLimit } from "./rate-limit"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("rateLimit", () => { + beforeEach(() => { + mock.timers.enable({ apis: ["setTimeout"] }); + }); + + 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); + + const promise = rateLimited(); + assert.strictEqual(mockFn.mock.callCount(), 1); + + 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"); + + const rateLimited = rateLimit(mockFn, 100); + + const promise1 = rateLimited(1); + await promise1; + + mock.timers.tick(200); + + const promise2 = rateLimited(2); + await promise2; + + 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); + + const promise1 = rateLimited("first"); + mock.timers.tick(10); + const promise2 = rateLimited("second"); + mock.timers.tick(10); + const promise3 = rateLimited("third"); + + mock.timers.tick(1000); + + 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"]); + }); +}); diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts new file mode 100644 index 00000000..4de89ae8 --- /dev/null +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -0,0 +1,58 @@ +import { createPromise } from "./create-promise"; +import { sleep } from "./sleep"; + +/** + * Creates a rate-limited version of a given asynchronous function. + * Ensures that the function is not called more frequently than specified by `minIntervalMs`. + * If the function is called while a previous call is still within the rate limit window, + * it will queue up the most recent arguments and execute them after the rate limit expires. + * Only the most recent call is preserved in the queue. + * + * @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 + * @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 +>( + fn: T, + minIntervalMs: number +): (...args: Parameters) => Promise { + let newArgs: Parameters | undefined = undefined; + let running: Promise | undefined = undefined; + + 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; + } + + const [promise, resolve] = createPromise(); + running = promise; + sleep(minIntervalMs) + .then(resolve) + .catch(() => { + // sleep cannot fail + }); + return fn(...args); + }; + + return decoratedFn; +} diff --git a/frontend/sync-client/src/utils/sleep.ts b/frontend/sync-client/src/utils/sleep.ts new file mode 100644 index 00000000..638fc019 --- /dev/null +++ b/frontend/sync-client/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/sync-client/tsconfig.json b/frontend/sync-client/tsconfig.json new file mode 100644 index 00000000..c49baa45 --- /dev/null +++ b/frontend/sync-client/tsconfig.json @@ -0,0 +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 diff --git a/frontend/sync-client/webpack.config.js b/frontend/sync-client/webpack.config.js new file mode 100644 index 00000000..d84a5cd4 --- /dev/null +++ b/frontend/sync-client/webpack.config.js @@ -0,0 +1,71 @@ +const path = require("path"); +const { merge } = require("webpack-merge"); +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 + } +}; + +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 + } + }) +]; diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json new file mode 100644 index 00000000..143c4881 --- /dev/null +++ b/frontend/test-client/package.json @@ -0,0 +1,25 @@ +{ + "name": "test-client", + "version": "0.8.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": "^22.15.30", + "bufferutil": "^4.0.9", + "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" + } +} diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts new file mode 100644 index 00000000..9e7806ab --- /dev/null +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -0,0 +1,333 @@ +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 { MockClient } from "./mock-client"; +import { sleep } from "../utils/sleep"; +import type { LogLine } from "sync-client/dist/types/tracing/logger"; + +export class MockAgent extends MockClient { + 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[] = []; + + public constructor( + initialSettings: Partial, + public readonly name: string, + private readonly doDeletes: 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 + ) + ); + + 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}`; + + // 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 (!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; + } + }); + + this.client.logger.info("Agent initialized"); + } + + 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)); + } + + const files = await this.listAllFiles(); + + 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)); + } + } + + 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 assertFileSystemsAreConsistent(otherAgent: MockAgent): void { + const globalFiles = Array.from(otherAgent.localFiles.keys()); + const localFiles = Array.from(this.localFiles.keys()); + + const missingInOther = localFiles.filter( + (file) => !otherAgent.localFiles.has(file) + ); + const missingInLocal = globalFiles.filter( + (file) => !this.localFiles.has(file) + ); + + 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(", ")}` + ); + + 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(", ") + ); + + throw e; + } + } + + 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; + } + + 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); + }); + + 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` + ); + + assert( + found.length <= 1, + `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` + ); + + 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}` + ); + } + } + } + + private async createFileAction(): Promise { + const file = this.getFileName(); + + if ( + (!this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(file)) || + (await this.exists(file)) + ) { + return; + } + + const content = this.getContent(); + this.client.logger.info( + `Decided to create file ${file} with content ${content}` + ); + + return this.create(file, new TextEncoder().encode(` ${content} `)); + } + + private async disableSyncAction(): Promise { + this.client.logger.info(`Decided to disable sync`); + await this.client.setSetting("isSyncEnabled", false); + } + + private async enableSyncAction(): Promise { + this.client.logger.info(`Decided to enable sync`); + await this.client.setSetting("isSyncEnabled", true); + } + + private async renameFileAction(files: RelativePath[]): Promise { + const file = choose(files); + + // 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; + } + + const newName = this.getFileName(); + + if ( + (!this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(newName)) || + (await this.exists(newName)) + ) { + return; + } + + this.client.logger.info(`Decided to rename file ${file} to ${newName}`); + this.doNotTouchWhileOffline.push(file, newName); + + return this.rename(file, newName); + } + + private async updateFileAction(files: RelativePath[]): Promise { + const file = choose(files); + + // 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; + } + + 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 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 new file mode 100644 index 00000000..3ef55c8f --- /dev/null +++ b/frontend/test-client/src/agent/mock-client.ts @@ -0,0 +1,195 @@ +import type { StoredDatabase } from "sync-client"; +import { assert } from "../utils/assert"; +import { + 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 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 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(); + } + + public async listAllFiles(): 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 getFileSize(path: RelativePath): Promise { + return (await this.read(path)).length; + } + + 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); + + 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 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}` + ); + } + ); + } + + this.client.logger.info( + `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` + ); + + this.executeFileOperation(async () => + this.client.syncLocallyUpdatedFile({ + relativePath: path + }) + ); + + return newContent; + } + + 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.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); + + 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); + } + + this.client.logger.info( + `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` + ); + + 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(); + } + } +} diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts new file mode 100644 index 00000000..4a3aab4f --- /dev/null +++ b/frontend/test-client/src/cli.ts @@ -0,0 +1,173 @@ +import type { SyncSettings } from "sync-client"; +import { MockAgent } from "./agent/mock-agent"; +import { sleep } from "./utils/sleep"; +import { v4 as uuidv4 } from "uuid"; +import { randomCasing } from "./utils/random-casing"; + +const TEST_ITERATIONS = 5; + +// Simulate async file access by injecting waiting time before returning from file operations. +let slowFileEvents = false; + +async function runTest({ + agentCount, + concurrency, + iterations, + doDeletes, + useSlowFileEvents, + jitterScaleInSeconds +}: { + agentCount: number; + concurrency: number; + iterations: number; + doDeletes: boolean; + useSlowFileEvents: boolean; + jitterScaleInSeconds: number; +}): Promise { + slowFileEvents = useSlowFileEvents; + + const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, 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 clients: MockAgent[] = []; + for (let i = 0; i < agentCount; i++) { + clients.push( + new MockAgent( + initialSettings, + `agent-${i}`, + doDeletes, + useSlowFileEvents, + jitterScaleInSeconds + ) + ); + } + + try { + await Promise.all(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); + } + + 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; + } + } + } + + // 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; + } + } + } + + 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`); + }); + + 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`); + }); + + 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 + }); + } + } + } + } +} + +process.on("uncaughtException", (error) => { + if (slowFileEvents) { + 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); +}); + +process.on("unhandledRejection", (error, _promise) => { + if (error instanceof Error && error.message === "Sync was reset") { + return; + } + + if (slowFileEvents) { + return; + } + + console.error("Unhandled rejection:", error); + process.exit(1); +}); + +runTests() + .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 new file mode 100644 index 00000000..e1e3bb98 --- /dev/null +++ b/frontend/test-client/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/test-client/src/utils/choose.ts b/frontend/test-client/src/utils/choose.ts new file mode 100644 index 00000000..adb1dc7c --- /dev/null +++ b/frontend/test-client/src/utils/choose.ts @@ -0,0 +1,3 @@ +export function choose(values: T[]): T { + 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 new file mode 100644 index 00000000..67033305 --- /dev/null +++ b/frontend/test-client/src/utils/random-casing.test.ts @@ -0,0 +1,13 @@ +import { describe, it } from "node:test"; +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); + }); +}); diff --git a/frontend/test-client/src/utils/random-casing.ts b/frontend/test-client/src/utils/random-casing.ts new file mode 100644 index 00000000..bf9f99dc --- /dev/null +++ b/frontend/test-client/src/utils/random-casing.ts @@ -0,0 +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(""); +} diff --git a/frontend/test-client/src/utils/sleep.ts b/frontend/test-client/src/utils/sleep.ts new file mode 100644 index 00000000..638fc019 --- /dev/null +++ b/frontend/test-client/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/test-client/tsconfig.json b/frontend/test-client/tsconfig.json new file mode 100644 index 00000000..7b38e409 --- /dev/null +++ b/frontend/test-client/tsconfig.json @@ -0,0 +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 diff --git a/frontend/test-client/webpack.config.js b/frontend/test-client/webpack.config.js new file mode 100644 index 00000000..b2324b9b --- /dev/null +++ b/frontend/test-client/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/manifest.json b/manifest.json new file mode 100644 index 00000000..21d6fa3e --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "vault-link", + "name": "VaultLink", + "version": "0.8.0", + "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 diff --git a/scripts/build-sync-server-binaries.sh b/scripts/build-sync-server-binaries.sh new file mode 100755 index 00000000..80d8d5e2 --- /dev/null +++ b/scripts/build-sync-server-binaries.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +cd "$(dirname "$0")/../sync-server" + +# Setup database +sqlx database create --database-url sqlite://db.sqlite3 2>/dev/null || true +sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 + +targets=${@:-"x86_64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-gnu x86_64-pc-windows-gnu"} + +mkdir -p artifacts +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) + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc ;; + x86_64-unknown-linux-musl) + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc ;; + 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 new file mode 100755 index 00000000..57a78fd6 --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -e + +if [[ -z $1 ]]; then + echo "Usage: $0 {patch|minor|major}" + exit 1 +fi + +if [[ $1 =~ ^(patch|minor|major)$ ]]; then + echo "Creating a new '$1' version" +else + echo "Invalid argument: $1" + echo "Usage: $0 {patch|minor|major}" + exit 1 +fi + +if [[ -n $(git status --porcelain) ]]; then + echo "Your working directory is not clean. Please commit or stash your changes before proceeding." + exit 1 +else + echo "Your working directory is clean." +fi + +echo "Bumping sync-server versions" +cd sync-server +cargo set-version --bump $1 + +echo "Bumping frontend versions" +cd ../frontend +npm version $1 --workspaces +cd .. + +cp frontend/obsidian-plugin/manifest.json manifest.json # for BRAT, otherwise it wouldn't update + +# Commit and tag +git add . +TAG=$(node -p "require('./frontend/obsidian-plugin/package.json').version") +git commit -m "Bump versions to $TAG" + +git push +echo "Tagging $TAG" +git tag -a $TAG -m "Release $TAG" +git push origin $TAG +echo "Done" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 00000000..03bb35fe --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +echo "Running checks in sync-server" +cd sync-server +cargo test --verbose +cargo clippy --all-targets --all-features +cargo fmt --all -- --check +cargo machete + +echo "Running checks in frontend" +cd ../frontend +npm ci +npm run build +npm run test +npm run lint + +if [[ $(git status --porcelain) ]]; then + git status --porcelain + echo "Failing CI because the working directory is not clean after linting" + exit 1 +fi + +echo "Success" + +cd .. diff --git a/scripts/clean-up.sh b/scripts/clean-up.sh new file mode 100755 index 00000000..4dfbf4a0 --- /dev/null +++ b/scripts/clean-up.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +rm -rf sync-server/databases +rm -rf logs diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 00000000..952e1855 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +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 + +# Check if the argument is provided +if [ $# -eq 0 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Get the number of processes from the first argument +process_count=$1 + +mkdir -p logs + +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+=($!) +done + +cd .. + +print_failed_log() { + for i in $(seq 1 $process_count); do + if [ -n "${pids[$i-1]}" ] && ! kill -0 ${pids[$i-1]} 2>/dev/null; then + # Get the exit code of the process + wait ${pids[$i-1]} + exit_code=$? + + # Only consider non-zero exit codes as failures + if [ $exit_code -ne 0 ]; then + 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 + else + echo "Process ${pids[$i-1]} completed successfully with exit code 0" + # Mark this PID as processed by setting it to empty + pids[$i-1]="" + fi + fi + done + return 1 +} + +echo "Monitoring $process_count processes" + +# Monitor processes +while true; do + if print_failed_log; then + # Kill remaining processes + for pid in "${pids[@]}"; do + if [ -n "$pid" ]; then + kill $pid 2>/dev/null || true + fi + done + exit 1 + fi + + # Check if all processes have completed + all_done=true + for pid in "${pids[@]}"; do + if [ -n "$pid" ] && kill -0 $pid 2>/dev/null; then + all_done=false + break + fi + done + + if $all_done; then + 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 new file mode 100755 index 00000000..5aa05d99 --- /dev/null +++ b/scripts/update-api-types.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +rm -rf sync-server/bindings + +cd sync-server +cargo test export_bindings +cd - + +cp -r sync-server/bindings/* frontend/sync-client/src/services/types/ + +cd frontend +npm run lint || npx prettier --write sync-client/src/services/types/*.ts +cd - diff --git a/scripts/utils/wait-for-server.sh b/scripts/utils/wait-for-server.sh new file mode 100755 index 00000000..7824c405 --- /dev/null +++ b/scripts/utils/wait-for-server.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -e + +SERVER_URL="http://localhost:3000" +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 + echo "$SERVER_URL is now available!" + break + fi + echo "Attempt $(($count+1))/$MAX_RETRIES: $SERVER_URL not available yet, retrying in ${RETRY_INTERVAL_IN_SECONDS}s..." + sleep $RETRY_INTERVAL_IN_SECONDS + count=$(($count+1)) +done + +if [ $count -eq $MAX_RETRIES ]; then + echo "Error: $SERVER_URL did not become available after $MAX_RETRIES attempts." + exit 1 +fi diff --git a/sync-server/.claude/settings.local.json b/sync-server/.claude/settings.local.json new file mode 100644 index 00000000..225fe664 --- /dev/null +++ b/sync-server/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo check:*)", + "Bash(rustup:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/sync-server/.dockerignore b/sync-server/.dockerignore new file mode 100644 index 00000000..091f4766 --- /dev/null +++ b/sync-server/.dockerignore @@ -0,0 +1,5 @@ +target +Dockerfile +.dockerignore +databases +*.yml diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock new file mode 100644 index 00000000..f33e946b --- /dev/null +++ b/sync-server/Cargo.lock @@ -0,0 +1,3109 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +dependencies = [ + "backtrace", +] + +[[package]] +name = "async-trait" +version = "0.1.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64 0.22.1", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "fastrand", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "axum_typed_multipart" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5a5d7e98a19e7ce06e96827ad86c1d75e9cb20ede5a70645dec2359a66cb7a" +dependencies = [ + "anyhow", + "axum", + "axum_typed_multipart_macros", + "bytes", + "chrono", + "futures-core", + "futures-util", + "tempfile", + "thiserror 1.0.69", + "tokio", + "uuid", +] + +[[package]] +name = "axum_typed_multipart_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2c550fa5c1a07bbc41dbec1dcd4d0e3de230b9072ab8fb70c55d7d37693d66d" +dependencies = [ + "darling", + "heck", + "proc-macro-error", + "quote", + "syn 2.0.90", + "ubyte", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" + +[[package]] +name = "cc" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +dependencies = [ + "clap_builder", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +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", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.2", +] + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "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" +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", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy 0.7.35", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", + "zerocopy 0.8.24", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + +[[package]] +name = "reconcile-text" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d690c19b0bf6574cd3591d10f20df5aa52d2af95b8dcaacbc86893292ac8c5" + +[[package]] +name = "redox_syscall" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +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" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.5", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rsa" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.7.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e" +dependencies = [ + "darling", + "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", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.2", + "hashlink", + "indexmap 2.7.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.90", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.90", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.12", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.12", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_server" +version = "0.8.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "axum_typed_multipart", + "base64 0.22.1", + "bimap", + "chrono", + "clap", + "clap-verbosity-flag", + "futures", + "log", + "rand 0.9.0", + "reconcile-text", + "regex", + "sanitize-filename", + "serde", + "serde_json", + "serde_with", + "serde_yaml", + "sqlx", + "thiserror 2.0.12", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "ts-rs", + "uuid", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "chrono", + "lazy_static", + "thiserror 2.0.12", + "ts-rs-macros", + "uuid", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "termcolor", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ubyte" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" + +[[package]] +name = "unicode-bidi" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "unicode-normalization" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.90", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" + +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-link" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive 0.8.24", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml new file mode 100644 index 00000000..9ccc6622 --- /dev/null +++ b/sync-server/Cargo.toml @@ -0,0 +1,89 @@ +[package] +name = "sync_server" +rust-version = "1.89.0" +authors = ["Andras Schmelczer "] +edition = "2024" +license = "MIT" +repository = "https://github.com/schmelczer/vault-link" +version = "0.8.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"]} +uuid = { version = "1.16.0", features = ["v4", "serde"] } +log = { version = "0.4.27" } +anyhow = { version = "1.0.98", 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"]} +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" +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.12.0" +base64 = "0.22.1" +reconcile-text = "0.5.0" + +[profile.release] +codegen-units = 1 +lto = true +opt-level = 3 +strip="debuginfo" # Keep some info for better panics + +[lints.rust] +unsafe_code = "forbid" +rust_2018_idioms = { level = "warn", priority = -1 } +missing_debug_implementations = "warn" + +[lints.clippy] +await_holding_lock = "warn" +dbg_macro = "warn" +empty_enum = "warn" +enum_glob_use = "warn" +exit = "warn" +filter_map_next = "warn" +fn_params_excessive_bools = "warn" +if_let_mutex = "warn" +imprecise_flops = "warn" +inefficient_to_string = "warn" +linkedlist = "warn" +lossy_float_literal = "warn" +macro_use_imports = "warn" +match_wildcard_for_single_variants = "warn" +mem_forget = "warn" +needless_borrow = "warn" +needless_continue = "warn" +option_option = "warn" +rest_pat_in_fully_bound_structs = "warn" +str_to_string = "warn" +suboptimal_flops = "warn" +todo = "warn" +uninlined_format_args = "warn" +unnested_or_patterns = "warn" +unused_self = "warn" +verbose_file_reads = "warn" + +large_stack_arrays = { level = "allow", priority = 1 } # https://github.com/rust-lang/rust-clippy/issues/13774 + +# Silly lints +implicit_return = { level = "allow", priority = 1 } +question_mark_used = { level = "allow", priority = 1 } +struct_field_names = { level = "allow", priority = 1 } +single_char_lifetime_names = { level = "allow", priority = 1 } +single_call_fn = { level = "allow", priority = 1 } +similar_names = { level = "allow", priority = 1 } +missing_docs_in_private_items = { level = "allow", priority = 1 } + +pedantic = { level = "warn", priority = 0 } diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile new file mode 100644 index 00000000..10aeb4ae --- /dev/null +++ b/sync-server/Dockerfile @@ -0,0 +1,34 @@ +FROM rust:1.89-slim-trixie AS builder + +WORKDIR /usr/src/backend + +RUN apt update && \ + apt install -y libssl-dev pkg-config && \ + cargo install sqlx-cli + +# Build application +COPY . . + +RUN sqlx database create --database-url sqlite://db.sqlite3 && \ + sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 && \ + cargo build --release + +FROM debian:trixie-slim + +LABEL org.opencontainers.image.authors="andras@schmelczer.dev" + +RUN apt update && \ + apt install -y curl ca-certificates && \ + apt clean && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/src/backend/target/release/sync_server /app/sync_server + +VOLUME /data +EXPOSE 3000/tcp +WORKDIR /data + +HEALTHCHECK --interval=30s --timeout=5s \ + CMD curl -f http://localhost:3000/vaults/fake/ping || exit 1 + +ENTRYPOINT ["/app/sync_server"] diff --git a/sync-server/README.md b/sync-server/README.md new file mode 100644 index 00000000..4576162c --- /dev/null +++ b/sync-server/README.md @@ -0,0 +1,9 @@ +# Sync server + +## Creating/resetting the Database for development + +```sh +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 +``` diff --git a/sync-server/build.rs b/sync-server/build.rs new file mode 100644 index 00000000..d5068697 --- /dev/null +++ b/sync-server/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml new file mode 100644 index 00000000..5f2346d6 --- /dev/null +++ b/sync-server/config-e2e.yml @@ -0,0 +1,26 @@ +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: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default diff --git a/sync-server/rust-toolchain.toml b/sync-server/rust-toolchain.toml new file mode 100644 index 00000000..635d09fb --- /dev/null +++ b/sync-server/rust-toolchain.toml @@ -0,0 +1,9 @@ +[toolchain] +channel = "1.89.0" +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-gnu", +] +profile = "default" diff --git a/sync-server/src/app_state.rs b/sync-server/src/app_state.rs new file mode 100644 index 00000000..a61467d5 --- /dev/null +++ b/sync-server/src/app_state.rs @@ -0,0 +1,41 @@ +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}; + +#[derive(Clone, Debug)] +pub struct AppState { + pub config: Config, + pub database: Database, + pub cursors: Cursors, + pub broadcasts: Broadcasts, +} + +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?; + let broadcasts = Broadcasts::new(&config.server); + let database = Database::try_new(&config.database, &broadcasts).await?; + let cursors: Cursors = Cursors::new(&config.database, &broadcasts); + + Cursors::start_background_task(cursors.clone()); + + Ok(Self { + config, + database, + cursors, + broadcasts, + }) + } +} diff --git a/sync-server/src/app_state/cursors.rs b/sync-server/src/app_state/cursors.rs new file mode 100644 index 00000000..d083e1ac --- /dev/null +++ b/sync-server/src/app_state/cursors.rs @@ -0,0 +1,132 @@ +use core::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use tokio::sync::Mutex; + +use super::{ + database::models::{DeviceId, VaultId}, + websocket::{ + broadcasts::Broadcasts, + models::{ + ClientCursors, CursorPositionFromServer, WebSocketServerMessage, + WebSocketServerMessageWithOrigin, + }, + }, +}; +use crate::{ + app_state::websocket::models::DocumentWithCursors, config::database_config::DatabaseConfig, +}; + +#[derive(Clone, Debug)] +pub struct Cursors { + config: DatabaseConfig, + broadcasts: Broadcasts, + vault_to_cursors: Arc>>>, +} + +impl Cursors { + pub fn new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Self { + Self { + config: config.clone(), + broadcasts: broadcasts.clone(), + vault_to_cursors: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn update_cursors( + &self, + vault_id: VaultId, + user_name: String, + device_id: &DeviceId, + document_to_cursors: Vec, + ) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new); + + all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); + all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { + user_name, + device_id: device_id.clone(), + documents_with_cursors: document_to_cursors, + })); + + drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock + self.broadcast_cursors().await; + } + + pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec { + let vault_to_cursors = self.vault_to_cursors.lock().await; + vault_to_cursors + .get(vault_id) + .map(|cursors| { + cursors + .iter() + .cloned() + .map(|with_ttl| with_ttl.client_cursors) + .collect::>() + }) + .unwrap_or_default() + } + + pub fn start_background_task(self) { + tokio::spawn(async move { + loop { + self.remove_expired_cursors().await; + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + } + + async fn remove_expired_cursors(&self) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + for (_vault_id, cursors) in vault_to_cursors.iter_mut() { + cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); + } + } + + async fn broadcast_cursors(&self) { + let vault_to_cursors = self.vault_to_cursors.lock().await; + + for (vault_id, cursors) in vault_to_cursors.iter() { + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( + CursorPositionFromServer { + clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(), + }, + )), + ) + .await; + } + } + + pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; + + if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { + cursors.retain(|c| c.client_cursors.device_id != device_id); + } + } +} + +#[derive(Clone, Debug)] +struct ClientCursorsWithTimeToLive { + client_cursors: ClientCursors, + last_updated: std::time::Instant, +} + +impl ClientCursorsWithTimeToLive { + fn new(client_cursors: ClientCursors) -> Self { + Self { + client_cursors, + last_updated: std::time::Instant::now(), + } + } + + pub fn is_expired(&self, ttl: Duration) -> bool { + self.last_updated.elapsed() > ttl + } +} diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs new file mode 100644 index 00000000..f8940140 --- /dev/null +++ b/sync-server/src/app_state/database.rs @@ -0,0 +1,425 @@ +use core::time::Duration; +use std::{collections::HashMap, sync::Arc}; + +use anyhow::{Context as _, Result}; +use models::{ + DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId, +}; +use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc}; + +pub mod models; +use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; +use tokio::sync::Mutex; +use uuid::fmt::Hyphenated; + +use super::websocket::{ + broadcasts::Broadcasts, + models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, +}; +use crate::config::database_config::DatabaseConfig; + +#[derive(Clone, Debug)] +pub struct Database { + config: DatabaseConfig, + broadcasts: Broadcasts, + connection_pools: Arc>>>, +} + +pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; + +impl Database { + pub async fn try_new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Result { + tokio::fs::create_dir_all(&config.databases_directory_path) + .await + .with_context(|| { + format!( + "Failed to create databases directory: {}", + config.databases_directory_path.to_string_lossy() + ) + })?; + + let mut connection_pools = std::collections::HashMap::new(); + + 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") { + continue; + } + + let vault: VaultId = entry + .file_name() + .to_string_lossy() + .trim_end_matches(".sqlite") + .to_owned(); + + connection_pools.insert( + vault.clone(), + Self::create_vault_database(config, &vault).await?, + ); + } + + Ok(Self { + config: config.clone(), + connection_pools: Arc::new(Mutex::new(connection_pools)), + broadcasts: broadcasts.clone(), + }) + } + + async fn create_vault_database( + config: &DatabaseConfig, + vault: &VaultId, + ) -> Result> { + let file_name = config + .databases_directory_path + .join(format!("{vault}.sqlite")); + + let connection_options = SqliteConnectOptions::new() + .filename(file_name.clone()) + .create_if_missing(true) + .busy_timeout(Duration::from_secs(3600)) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); + + let pool = SqlitePoolOptions::new() + .max_connections(config.max_connections_per_vault) + .test_before_acquire(true) + .connect_with(connection_options) + .await + .with_context(|| format!("Cannot open database at {}", file_name.display()))?; + + Self::run_migrations(&pool).await?; + + Ok(pool) + } + + async fn run_migrations(pool: &Pool) -> Result<()> { + sqlx::migrate!("src/app_state/database/migrations") + .run(pool) + .await + .context("Cannot check for pending migrations") + } + + 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); + } + + let pool = pools + .get(vault) + .expect("Pool was just inserted or already exists"); + + Ok(pool.clone()) + } + + /// Attempting to write from this transaction might result in a + /// database locked error. Use this transaction for read-only operations. + pub async fn create_readonly_transaction( + &self, + vault: &VaultId, + ) -> Result> { + self.get_connection_pool(vault) + .await? + .begin() + .await + .context("Cannot create transaction") + } + + pub async fn create_write_transaction(&self, vault: &VaultId) -> Result> { + let mut transaction = self.create_readonly_transaction(vault).await?; + + // sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481 + sqlx::query!("END; BEGIN IMMEDIATE;") + .execute(&mut *transaction) + .await?; + + Ok(transaction) + } + + /// Return the latest state of all documents in the vault + pub async fn get_latest_documents( + &self, + vault: &VaultId, + transaction: Option<&mut Transaction<'_>>, + ) -> Result> { + let query = sqlx::query!( + r#" + select + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + is_deleted, + user_id, + device_id, + length(content) as "content_size: u64" + from latest_document_versions + order by vault_update_id + "#, + ); + + if let Some(transaction) = transaction { + query.fetch_all(&mut **transaction).await + } else { + query + .fetch_all(&self.get_connection_pool(vault).await?) + .await + } + .context("Cannot fetch latest documents") + .map(|rows| { + rows.into_iter() + .map(|row| DocumentVersionWithoutContent { + vault_update_id: row.vault_update_id, + document_id: row.document_id.into(), + relative_path: row.relative_path, + updated_date: row.updated_date, + is_deleted: row.is_deleted, + user_id: row.user_id, + device_id: row.device_id, + content_size: row + .content_size + .expect("Content size can't be null but sqlx can't infer it"), + }) + .collect() + }) + } + + /// Return the latest state of all documents (including deleted) in the + /// vault which have changed since the given update id + pub async fn get_latest_documents_since( + &self, + vault: &VaultId, + vault_update_id: VaultUpdateId, + transaction: Option<&mut Transaction<'_>>, + ) -> Result> { + let query = sqlx::query!( + r#" + select + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + is_deleted, + user_id, + device_id, + length(content) as "content_size: u64" + from latest_document_versions + where vault_update_id > ? + order by vault_update_id + "#, + vault_update_id + ); + + if let Some(transaction) = transaction { + query.fetch_all(&mut **transaction).await + } else { + query + .fetch_all(&self.get_connection_pool(vault).await?) + .await + } + .with_context(|| { + format!("Cannot fetch latest documents since vault_update_id {vault_update_id}") + }) + .map(|rows| { + rows.into_iter() + .map(|row| DocumentVersionWithoutContent { + vault_update_id: row.vault_update_id, + document_id: row.document_id.into(), + relative_path: row.relative_path, + updated_date: row.updated_date, + is_deleted: row.is_deleted, + user_id: row.user_id, + device_id: row.device_id, + content_size: row + .content_size + .expect("Content size can't be null but sqlx can't infer it"), + }) + .collect() + }) + } + + pub async fn get_max_update_id_in_vault( + &self, + vault: &VaultId, + transaction: Option<&mut Transaction<'_>>, + ) -> Result { + let query = sqlx::query!( + r#" + select coalesce(max(vault_update_id), 0) as max_vault_update_id + from documents + "#, + ); + + if let Some(transaction) = transaction { + query.fetch_one(&mut **transaction).await + } else { + query + .fetch_one(&self.get_connection_pool(vault).await?) + .await + } + .map(|row| row.max_vault_update_id) + .context("Cannot fetch max update id in vault") + } + + pub async fn get_latest_document_by_path( + &self, + vault: &VaultId, + relative_path: &str, + transaction: Option<&mut Transaction<'_>>, + ) -> Result> { + let query = sqlx::query_as!( + StoredDocumentVersion, + r#" + select + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + content, + is_deleted, + user_id, + device_id + from latest_document_versions + where relative_path = ? + 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. + limit 1 + "#, + relative_path + ); + + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await + } else { + query + .fetch_optional(&self.get_connection_pool(vault).await?) + .await + } + .context("Cannot fetch latest document version") + } + + pub async fn get_latest_document( + &self, + vault: &VaultId, + document_id: &DocumentId, + transaction: Option<&mut Transaction<'_>>, + ) -> Result> { + let document_id = document_id.as_hyphenated(); + let query = sqlx::query_as!( + StoredDocumentVersion, + r#" + select + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + content, + is_deleted, + user_id, + device_id + from latest_document_versions + where document_id = ? + "#, + document_id + ); + + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await + } else { + query + .fetch_optional(&self.get_connection_pool(vault).await?) + .await + } + .context("Cannot fetch latest document version") + } + + pub async fn get_document_version( + &self, + vault: &VaultId, + vault_update_id: VaultUpdateId, + transaction: Option<&mut Transaction<'_>>, + ) -> Result> { + let query = sqlx::query_as!( + StoredDocumentVersion, + r#" + select + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + content, + is_deleted, + user_id, + device_id + from documents + where vault_update_id = ?"#, + vault_update_id + ); + + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await + } else { + query + .fetch_optional(&self.get_connection_pool(vault).await?) + .await + } + .context("Cannot fetch document version") + } + + pub async fn insert_document_version( + &self, + vault_id: &VaultId, + version: &StoredDocumentVersion, + transaction: Option<&mut Transaction<'_>>, + ) -> Result<()> { + let document_id = version.document_id.as_hyphenated(); + let query = sqlx::query!( + r#" + insert into documents ( + vault_update_id, + document_id, + relative_path, + updated_date, + content, + is_deleted, + user_id, + device_id + ) + values (?, ?, ?, ?, ?, ?, ?, ?) + "#, + version.vault_update_id, + document_id, + version.relative_path, + version.updated_date, + version.content, + version.is_deleted, + version.user_id, + version.device_id + ); + + if let Some(transaction) = transaction { + query.execute(&mut **transaction).await + } else { + query + .execute(&self.get_connection_pool(vault_id).await?) + .await + } + .context("Cannot insert document version")?; + + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: vec![version.clone().into()], + is_initial_sync: false, + }), + ), + ) + .await; + + Ok(()) + } +} diff --git a/sync-server/src/app_state/database/migrations/20241207143519_bootstrap.sql b/sync-server/src/app_state/database/migrations/20241207143519_bootstrap.sql new file mode 100644 index 00000000..4a9f31ba --- /dev/null +++ b/sync-server/src/app_state/database/migrations/20241207143519_bootstrap.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS documents ( + vault_update_id INTEGER NOT NULL PRIMARY KEY, + document_id TEXT NOT NULL, + relative_path TEXT NOT NULL, + updated_date TIMESTAMP NOT NULL, + content BLOB NOT NULL, + is_deleted BOOLEAN NOT NULL +); + +CREATE VIEW IF NOT EXISTS latest_document_versions AS +SELECT d.* +FROM documents d +INNER JOIN ( + SELECT MAX(vault_update_id) AS max_version_id + FROM documents + GROUP BY document_id +) max_versions +ON d.vault_update_id = max_versions.max_version_id; + +CREATE INDEX IF NOT EXISTS idx_documents_vault_id_relative_path +ON documents (relative_path); diff --git a/sync-server/src/app_state/database/migrations/20250522192949_add_provenance_columns.sql b/sync-server/src/app_state/database/migrations/20250522192949_add_provenance_columns.sql new file mode 100644 index 00000000..06860174 --- /dev/null +++ b/sync-server/src/app_state/database/migrations/20250522192949_add_provenance_columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents ADD COLUMN user_id TEXT NOT NULL DEFAULT ""; +ALTER TABLE documents ADD COLUMN device_id TEXT NOT NULL DEFAULT ""; diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs new file mode 100644 index 00000000..24c0c370 --- /dev/null +++ b/sync-server/src/app_state/database/models.rs @@ -0,0 +1,91 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use ts_rs::TS; + +pub type VaultId = String; +pub type VaultUpdateId = i64; + +pub type DocumentId = uuid::Uuid; +pub type UserId = String; +pub type DeviceId = String; + +#[derive(Debug, Clone)] +pub struct StoredDocumentVersion { + pub vault_update_id: VaultUpdateId, + pub document_id: DocumentId, + pub relative_path: String, + pub updated_date: DateTime, + pub content: Vec, + pub is_deleted: bool, + pub user_id: UserId, + pub device_id: DeviceId, +} + +impl PartialEq for StoredDocumentVersion { + fn eq(&self, other: &Self) -> bool { + self.vault_update_id == other.vault_update_id + } +} + +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentVersionWithoutContent { + #[ts(as = "i32")] + pub vault_update_id: VaultUpdateId, + + pub document_id: DocumentId, + pub relative_path: String, + pub updated_date: DateTime, + pub is_deleted: bool, + pub user_id: UserId, + pub device_id: DeviceId, + + #[ts(as = "i32")] + pub content_size: u64, +} + +impl From for DocumentVersionWithoutContent { + fn from(value: StoredDocumentVersion) -> Self { + Self { + vault_update_id: value.vault_update_id, + document_id: value.document_id, + relative_path: value.relative_path, + updated_date: value.updated_date, + is_deleted: value.is_deleted, + user_id: value.user_id, + device_id: value.device_id, + content_size: value.content.len() as u64, + } + } +} + +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentVersion { + #[ts(as = "i32")] + pub vault_update_id: VaultUpdateId, + + pub document_id: DocumentId, + pub relative_path: String, + pub updated_date: DateTime, + pub content_base64: String, + pub is_deleted: bool, + pub user_id: UserId, + pub device_id: DeviceId, +} + +impl From for DocumentVersion { + fn from(value: StoredDocumentVersion) -> Self { + Self { + vault_update_id: value.vault_update_id, + document_id: value.document_id, + relative_path: value.relative_path, + updated_date: value.updated_date, + content_base64: STANDARD.encode(&value.content), + is_deleted: value.is_deleted, + user_id: value.user_id, + device_id: value.device_id, + } + } +} diff --git a/sync-server/src/app_state/websocket.rs b/sync-server/src/app_state/websocket.rs new file mode 100644 index 00000000..b945606f --- /dev/null +++ b/sync-server/src/app_state/websocket.rs @@ -0,0 +1,3 @@ +pub mod broadcasts; +pub mod models; +pub mod utils; diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs new file mode 100644 index 00000000..cef6ee6a --- /dev/null +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -0,0 +1,63 @@ +use std::{collections::HashMap, sync::Arc}; + +use anyhow::Context; +use tokio::sync::{Mutex, broadcast}; + +use super::models::WebSocketServerMessageWithOrigin; +use crate::{ + app_state::database::models::VaultId, config::server_config::ServerConfig, errors::server_error, +}; + +#[derive(Debug, Clone)] +pub struct Broadcasts { + max_clients_per_vault: usize, + tx: Arc>>>, +} + +impl Broadcasts { + pub fn new(server_config: &ServerConfig) -> Self { + Self { + max_clients_per_vault: server_config.max_clients_per_vault, + tx: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn get_receiver( + &self, + vault: VaultId, + ) -> broadcast::Receiver { + let tx = self.get_or_create(vault).await; + + tx.subscribe() + } + + /// Notify all clients (who are subscribed to the vault) about an update. + /// We only log failures. + pub async fn send_document_update( + &self, + vault: VaultId, + document: WebSocketServerMessageWithOrigin, + ) { + let tx = self.get_or_create(vault).await; + + let result = tx + .send(document) + .context("Cannot broadcast server message to websocket listeners") + .map_err(server_error); + + if result.is_err() { + log::debug!("Failed to send message: {result:?}"); + } + } + + async fn get_or_create( + &self, + vault: VaultId, + ) -> broadcast::Sender { + let mut tx = self.tx.lock().await; + + tx.entry(vault) + .or_insert_with(|| broadcast::channel(self.max_clients_per_vault).0.clone()) + .clone() + } +} diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs new file mode 100644 index 00000000..e037fb7e --- /dev/null +++ b/sync-server/src/app_state/websocket/models.rs @@ -0,0 +1,103 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::app_state::database::models::{ + DeviceId, DocumentId, DocumentVersionWithoutContent, VaultUpdateId, +}; + +#[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketHandshake { + pub token: String, + pub device_id: DeviceId, + + #[ts(as = "Option")] + pub last_seen_vault_update_id: Option, +} + +#[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorPositionFromClient { + pub documents_with_cursors: Vec, +} + +#[derive(TS, Serialize, Deserialize, Clone, Debug)] +pub struct DocumentWithCursors { + // It's None in case the document is dirty. + // We still want to sync the cursor to mark + // that it exists and can be client-side + // interpolated. However, the actual + // position is meaningless. + #[ts(as = "Option")] + pub vault_update_id: Option, + + pub document_id: DocumentId, + pub relative_path: String, + pub cursors: Vec, +} + +#[derive(TS, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorSpan { + pub start: usize, + pub end: usize, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ClientCursors { + pub user_name: String, + pub device_id: DeviceId, + pub documents_with_cursors: Vec, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CursorPositionFromServer { + pub clients: Vec, +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketVaultUpdate { + pub documents: Vec, + pub is_initial_sync: bool, +} + +#[derive(TS, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +pub enum WebSocketClientMessage { + Handshake(WebSocketHandshake), + CursorPositions(CursorPositionFromClient), +} + +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase", tag = "type")] +#[ts(export)] +pub enum WebSocketServerMessage { + VaultUpdate(WebSocketVaultUpdate), + CursorPositions(CursorPositionFromServer), +} + +#[derive(Clone, Debug)] +pub struct WebSocketServerMessageWithOrigin { + pub origin_device_id: Option, + pub message: WebSocketServerMessage, +} + +impl WebSocketServerMessageWithOrigin { + pub fn new(message: WebSocketServerMessage) -> Self { + Self { + origin_device_id: None, + message, + } + } + + pub fn with_origin(origin_device_id: DeviceId, message: WebSocketServerMessage) -> Self { + Self { + origin_device_id: Some(origin_device_id), + message, + } + } +} diff --git a/sync-server/src/app_state/websocket/utils.rs b/sync-server/src/app_state/websocket/utils.rs new file mode 100644 index 00000000..1e0dd243 --- /dev/null +++ b/sync-server/src/app_state/websocket/utils.rs @@ -0,0 +1,80 @@ +use anyhow::Context; +use axum::extract::ws::{Message, WebSocket}; +use futures::{sink::SinkExt, stream::SplitSink}; + +use super::models::{WebSocketClientMessage, WebSocketHandshake, WebSocketServerMessage}; +use crate::{ + app_state::{ + AppState, + database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}, + }, + config::user_config::User, + errors::{SyncServerError, server_error, unauthenticated_error}, + server::auth::auth, +}; + +pub struct AuthenticatedWebSocketHandshake { + pub handshake: WebSocketHandshake, + pub user: User, +} + +pub fn get_authenticated_handshake( + state: &AppState, + vault_id: &VaultId, + message: Option, +) -> Result { + if let Some(Message::Text(message)) = message { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse message") + .map_err(server_error)?; + + match message { + WebSocketClientMessage::Handshake(handshake) => { + let user = auth(state, handshake.token.trim(), vault_id)?; + Ok(AuthenticatedWebSocketHandshake { handshake, user }) + } + WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( + anyhow::anyhow!("Expected a handshake message"), + )), + } + } else { + Err(unauthenticated_error(anyhow::anyhow!( + "Failed to authenticate due to invalid message" + ))) + } +} + +pub async fn get_unseen_documents( + state: &AppState, + vault_id: &VaultId, + last_seen_vault_update_id: Option, +) -> Result, SyncServerError> { + if let Some(update_id) = last_seen_vault_update_id { + state + .database + .get_latest_documents_since(vault_id, update_id, None) + .await + .map_err(server_error) + } else { + state + .database + .get_latest_documents(vault_id, None) + .await + .map_err(server_error) + } +} + +pub async fn send_update_over_websocket( + update: &WebSocketServerMessage, + sender: &mut SplitSink, +) -> Result<(), SyncServerError> { + let serialized_update = serde_json::to_string(update) + .context("Failed to serialize update") + .map_err(server_error)?; + + sender + .send(Message::Text(serialized_update)) + .await + .context("Failed to send message over websocket") + .map_err(server_error) +} diff --git a/sync-server/src/cli.rs b/sync-server/src/cli.rs new file mode 100644 index 00000000..d5c08521 --- /dev/null +++ b/sync-server/src/cli.rs @@ -0,0 +1,2 @@ +pub mod args; +pub mod color_when; diff --git a/sync-server/src/cli/args.rs b/sync-server/src/cli/args.rs new file mode 100644 index 00000000..603d8d15 --- /dev/null +++ b/sync-server/src/cli/args.rs @@ -0,0 +1,26 @@ +use std::ffi::OsString; + +use clap::Parser; +use clap_verbosity_flag::{InfoLevel, Verbosity}; + +use crate::cli::color_when::ColorWhen; + +/// Server for backing the `VaultLink` plugin +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +pub struct Args { + #[arg(index = 1)] + pub config_path: Option, + + #[command(flatten)] + pub verbose: Verbosity, + + #[arg( + long, + value_name = "WHEN", + default_value_t = ColorWhen::Auto, + default_missing_value = "always", + value_enum + )] + pub color: ColorWhen, +} diff --git a/sync-server/src/cli/color_when.rs b/sync-server/src/cli/color_when.rs new file mode 100644 index 00000000..a3709b94 --- /dev/null +++ b/sync-server/src/cli/color_when.rs @@ -0,0 +1,31 @@ +use std::io::IsTerminal; + +use clap::ValueEnum; + +#[derive(ValueEnum, Copy, Clone, Debug, PartialEq, Eq)] +pub enum ColorWhen { + Always, + Auto, + Never, +} + +impl ColorWhen { + pub fn use_colors(self) -> bool { + match self { + ColorWhen::Always => true, + ColorWhen::Auto => { + std::env::var_os("NO_COLOR").is_none() && std::io::stderr().is_terminal() + } + ColorWhen::Never => false, + } + } +} + +impl std::fmt::Display for ColorWhen { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.to_possible_value() + .expect("no values are skipped") + .get_name() + .fmt(f) + } +} diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs new file mode 100644 index 00000000..700b1ea8 --- /dev/null +++ b/sync-server/src/config.rs @@ -0,0 +1,66 @@ +use std::path::Path; + +use anyhow::{Context as _, Result}; +use database_config::DatabaseConfig; +use log::info; +use serde::{Deserialize, Serialize}; +use server_config::ServerConfig; +use tokio::fs; +use user_config::UserConfig; + +pub mod database_config; +pub mod server_config; +pub mod user_config; + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct Config { + #[serde(default)] + pub database: DatabaseConfig, + #[serde(default)] + pub server: ServerConfig, + #[serde(default)] + pub users: UserConfig, +} + +impl Config { + pub async fn read_or_create(path: &Path) -> Result { + let config = if path.exists() { + info!( + "Loading configuration from '{}'", + path.canonicalize().unwrap().display() + ); + Self::load_from_file(path).await? + } else { + Self::default() + }; + + config.write(path).await?; + info!( + "Updated configuration at '{}'", + path.canonicalize().unwrap().display() + ); + + Ok(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 {}", + path.display() + ) + })?; + + let config = serde_yaml::from_str(&contents).context("Failed to parse configuration")?; + + Ok(config) + } + + pub async fn write(&self, path: &Path) -> Result<()> { + let contents = serde_yaml::to_string(&self).context("Failed to serialize configuration")?; + + fs::write(path, contents) + .await + .context("Failed to write configuration to disk") + } +} diff --git a/sync-server/src/config/database_config.rs b/sync-server/src/config/database_config.rs new file mode 100644 index 00000000..f1c92d9d --- /dev/null +++ b/sync-server/src/config/database_config.rs @@ -0,0 +1,48 @@ +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")] + pub databases_directory_path: PathBuf, + + #[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")] + pub cursor_timeout: Duration, +} + +fn default_databases_directory_path() -> PathBuf { + debug!("Using default databases directory path: {DEFAULT_DATABASES_DIRECTORY_PATH:?}"); + PathBuf::from(DEFAULT_DATABASES_DIRECTORY_PATH) +} + +fn default_max_connections_per_vault() -> u32 { + debug!("Using default max connections: {DEFAULT_MAX_CONNECTIONS_PER_VAULT}"); + DEFAULT_MAX_CONNECTIONS_PER_VAULT +} + +fn default_cursor_timeout() -> Duration { + debug!("Using default cursor timeout: {DEFAULT_CURSOR_TIMEOUT:?}"); + DEFAULT_CURSOR_TIMEOUT +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + databases_directory_path: default_databases_directory_path(), + max_connections_per_vault: default_max_connections_per_vault(), + cursor_timeout: default_cursor_timeout(), + } + } +} diff --git a/sync-server/src/config/server_config.rs b/sync-server/src/config/server_config.rs new file mode 100644 index 00000000..ce922fb9 --- /dev/null +++ b/sync-server/src/config/server_config.rs @@ -0,0 +1,50 @@ +use log::debug; +use serde::{Deserialize, Serialize}; + +use crate::consts::{ + DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_PORT, + DEFAULT_RESPONSE_TIMEOUT_SECONDS, +}; + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct ServerConfig { + #[serde(default = "default_host")] + pub host: String, + + #[serde(default = "default_port")] + pub port: u16, + + #[serde(default = "default_max_body_size_mb")] + pub max_body_size_mb: usize, + + #[serde(default = "default_max_clients_per_vault")] + pub max_clients_per_vault: usize, + + #[serde(default = "default_response_timeout_seconds")] + pub response_timeout_seconds: u64, +} + +fn default_host() -> String { + debug!("Using default server host: {DEFAULT_HOST}"); + DEFAULT_HOST.to_owned() +} + +fn default_port() -> u16 { + debug!("Using default server port: {DEFAULT_PORT}"); + DEFAULT_PORT +} + +fn default_max_body_size_mb() -> usize { + debug!("Using default max body size (MB): {DEFAULT_MAX_BODY_SIZE_MB}"); + DEFAULT_MAX_BODY_SIZE_MB +} + +fn default_max_clients_per_vault() -> usize { + debug!("Using default max clients per vault: {DEFAULT_MAX_CLIENTS_PER_VAULT}"); + DEFAULT_MAX_CLIENTS_PER_VAULT +} + +fn default_response_timeout_seconds() -> u64 { + debug!("Using default response timeout (seconds): {DEFAULT_RESPONSE_TIMEOUT_SECONDS}"); + DEFAULT_RESPONSE_TIMEOUT_SECONDS +} diff --git a/sync-server/src/config/user_config.rs b/sync-server/src/config/user_config.rs new file mode 100644 index 00000000..ed7ecc23 --- /dev/null +++ b/sync-server/src/config/user_config.rs @@ -0,0 +1,164 @@ +use bimap::BiHashMap; +use rand::{Rng, distr::Alphanumeric, rng}; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; + +use crate::app_state::database::models::VaultId; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct UserConfig { + #[serde(default = "default_users", deserialize_with = "validate_users")] + pub user_configs: Vec, +} + +fn validate_users<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let users = Vec::::deserialize(deserializer)?; + + let mut user_token_map = BiHashMap::new(); + 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.", + 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.", + user.name + ))); + } + + user_token_map.insert(user.name.clone(), user.token.clone()); + } + + Ok(users) +} + +impl UserConfig { + pub fn get_user(&self, token: &str) -> Option<&User> { + self.user_configs.iter().find(|u| u.token == token) + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct User { + pub name: String, + pub token: String, + pub vault_access: VaultAccess, +} + +impl Default for UserConfig { + fn default() -> Self { + Self { + user_configs: default_users(), + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum VaultAccess { + #[default] + AllowAccessToAll, + + AllowList(AllowListedVaults), +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct AllowListedVaults { + pub allowed: Vec, +} + +fn default_users() -> Vec { + vec![User { + name: "admin".to_owned(), + token: get_random_token(), + vault_access: VaultAccess::default(), + }] +} + +pub fn get_random_token() -> String { + rng() + .sample_iter(&Alphanumeric) + .take(64) + .map(char::from) + .collect() +} +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn test_validate_users_unique_names_and_tokens() { + let config_json = json!({ + "user_configs": [ + { + "name": "alice", + "token": "token1", + "vault_access": { "type": "allow_access_to_all" } + }, + { + "name": "bob", + "token": "token2", + "vault_access": { "type": "allow_access_to_all" } + } + ] + }); + + let config: Result = serde_json::from_value(config_json); + assert!(config.is_ok()); + } + + #[test] + fn test_validate_users_duplicate_names() { + let config_json = json!({ + "user_configs": [ + { + "name": "alice", + "token": "token1", + "vault_access": { "type": "allow_access_to_all" } + }, + { + "name": "alice", + "token": "token2", + "vault_access": { "type": "allow_access_to_all" } + } + ] + }); + + let config: Result = serde_json::from_value(config_json); + assert!(config.is_err()); + let err = config.unwrap_err().to_string(); + assert!(err.contains("Duplicate user name found")); + } + + #[test] + fn test_validate_users_duplicate_tokens() { + let config_json = json!({ + "user_configs": [ + { + "name": "alice", + "token": "token1", + "vault_access": { "type": "allow_access_to_all" } + }, + { + "name": "bob", + "token": "token1", + "vault_access": { "type": "allow_access_to_all" } + } + ] + }); + + let config: Result = serde_json::from_value(config_json); + assert!(config.is_err()); + let err = config.unwrap_err().to_string(); + assert!(err.contains("Duplicate user token found")); + } +} diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs new file mode 100644 index 00000000..df5a2844 --- /dev/null +++ b/sync-server/src/consts.rs @@ -0,0 +1,13 @@ +use std::time::Duration; + +pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; + +pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; +pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; +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_MAX_CLIENTS_PER_VAULT: usize = 256; diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs new file mode 100644 index 00000000..987c3011 --- /dev/null +++ b/sync-server/src/errors.rs @@ -0,0 +1,140 @@ +use std::fmt::Display; + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use log::{debug, error}; +use serde::Serialize; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Error, Debug)] +pub enum SyncServerError { + #[error("Initialisation error: {0}")] + InitError(#[source] anyhow::Error), + + #[error("Client error: {0:?}")] + ClientError(#[source] anyhow::Error), + + #[error("Server error: {0:?}")] + ServerError(#[source] anyhow::Error), + + #[error("Not found: {0}")] + NotFound(#[source] anyhow::Error), + + #[error("Unauthorized: {0}")] + Unauthenticated(#[source] anyhow::Error), + + #[error("Permission denied error: {0}")] + PermissionDeniedError(#[source] anyhow::Error), +} + +impl SyncServerError { + pub fn serialize(&self) -> SerializedError { + match self { + Self::InitError(error) + | Self::ClientError(error) + | Self::ServerError(error) + | Self::NotFound(error) + | Self::Unauthenticated(error) + | Self::PermissionDeniedError(error) => error.into(), + } + } +} + +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct SerializedError { + pub error_type: &'static str, + pub message: String, + pub causes: Vec, +} + +impl Display for SerializedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.causes.is_empty() { + write!(f, "\nCauses:\n")?; + for cause in &self.causes { + write!(f, "{}", &format!("- {cause}\n"))?; + } + } + + Ok(()) + } +} + +impl IntoResponse for SyncServerError { + fn into_response(self) -> Response { + let body = Json(self.serialize()); + + match self { + Self::InitError(_) | Self::ServerError(_) => { + (StatusCode::INTERNAL_SERVER_ERROR, body).into_response() + } + Self::ClientError(_) => (StatusCode::BAD_REQUEST, body).into_response(), + Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(), + Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(), + Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(), + } + } +} + +impl From<&anyhow::Error> for SerializedError { + fn from(error: &anyhow::Error) -> SerializedError { + let mut causes = vec![]; + let mut current_error = error.source(); + while let Some(error) = current_error { + causes.push(error.to_string()); + current_error = error.source(); + } + + SerializedError { + error_type: error.downcast_ref::().map_or( + "UnknownError", + |e| match e { + SyncServerError::InitError(_) => "InitError", + SyncServerError::ClientError(_) => "ClientError", + SyncServerError::ServerError(_) => "ServerError", + SyncServerError::NotFound(_) => "NotFound", + SyncServerError::Unauthenticated(_) => "Unauthenticated", + SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError", + }, + ), + message: error.to_string(), + causes, + } + } +} + +pub fn init_error(error: anyhow::Error) -> SyncServerError { + debug!("Initialization error: {error:?}"); + SyncServerError::InitError(error) +} + +pub fn server_error(error: anyhow::Error) -> SyncServerError { + debug!("Server error: {error:?}"); + SyncServerError::ServerError(error) +} + +pub fn client_error(error: anyhow::Error) -> SyncServerError { + debug!("Client error: {error:?}"); + SyncServerError::ClientError(error) +} + +pub fn not_found_error(error: anyhow::Error) -> SyncServerError { + debug!("Not found: {error:?}"); + SyncServerError::NotFound(error) +} + +pub fn unauthenticated_error(error: anyhow::Error) -> SyncServerError { + debug!("Unauthenticated user: {error:?}"); + SyncServerError::Unauthenticated(error) +} + +pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError { + debug!("Permission denied: {error:?}"); + SyncServerError::PermissionDeniedError(error) +} diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs new file mode 100644 index 00000000..83556542 --- /dev/null +++ b/sync-server/src/main.rs @@ -0,0 +1,86 @@ +mod app_state; +mod cli; +mod config; +mod consts; +mod errors; +mod server; +mod utils; + +use std::process::ExitCode; + +use anyhow::{Context as _, Result}; +use clap::Parser; +use cli::args::Args; +use errors::{SyncServerError, init_error}; +use log::info; +use server::create_server; +use tracing_subscriber::{EnvFilter, fmt::format, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> ExitCode { + let args = Args::parse(); + + let mut result = set_up_logging(&args); + + if result.is_ok() { + result = start_server(args).await; + } + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{}", e.serialize()); + ExitCode::FAILURE + } + } +} + +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, + }; + + let env_filter = EnvFilter::builder() + .with_default_directive(level_filter.into()) + .from_env() + .context("Failed to create logging env filter") + .map_err(init_error)?; + + let use_colors = args.color.use_colors(); + + let is_debug_mode = args.verbose.log_level_filter() >= log::LevelFilter::Debug; + + tracing_subscriber::fmt() + .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() + .try_init() + .context("Failed to initialise tracing") + .map_err(init_error)?; + + Ok(()) +} + +async fn start_server(args: Args) -> Result<(), SyncServerError> { + info!( + "Starting VaultLink server version {}", + env!("CARGO_PKG_VERSION") + ); + + create_server(args.config_path) + .await + .context("Failed to start server") + .map_err(init_error) +} diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs new file mode 100644 index 00000000..cddcc1b5 --- /dev/null +++ b/sync-server/src/server.rs @@ -0,0 +1,188 @@ +pub mod auth; +mod create_document; +mod delete_document; +mod device_id_header; +mod fetch_document_version; +mod fetch_document_version_content; +mod fetch_latest_document_version; +mod fetch_latest_documents; +mod index; +mod ping; +mod requests; +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::{ + Router, + extract::{DefaultBodyLimit, Request}, + http::{self, HeaderValue, Method}, + middleware, + response::IntoResponse, + routing::{IntoMakeService, delete, get, post, put}, +}; +use device_id_header::DEVICE_ID_HEADER_NAME; +use log::info; +use tokio::signal; +use tower_http::{ + LatencyUnit, + cors::CorsLayer, + limit::RequestBodyLimitLayer, + timeout::TimeoutLayer, + trace::{ + DefaultOnBodyChunk, DefaultOnEos, DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, + TraceLayer, + }, +}; +use tracing::{Level, info_span}; + +use crate::{ + app_state::AppState, + 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) + .await + .context("Failed to initialise app state")?; + + let server_config = app_state.config.server.clone(); + + let app = Router::new() + .nest("/", get_authed_routes(app_state.clone())) + .route("/", get(index::index)) + .route("/vaults/:vault_id/ping", get(ping::ping)) + .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) + .layer(DefaultBodyLimit::disable()) + .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( + CorsLayer::new() + .allow_origin("*".parse::().expect("Failed to parse origin")) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + DEVICE_ID_HEADER_NAME.clone(), + ]) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]), + ) + .layer( + TraceLayer::new_for_http() + .make_span_with(|request: &Request<_>| { + info_span!( + "http", + method = ?request.method(), + uri = ?request.uri(), + ) + }) + .on_request(DefaultOnRequest::new().level(Level::INFO)) + .on_response( + DefaultOnResponse::new() + .level(Level::INFO) + .latency_unit(LatencyUnit::Millis), + ) + .on_body_chunk(DefaultOnBodyChunk::new()) + .on_eos(DefaultOnEos::new()) + .on_failure(DefaultOnFailure::new().level(Level::ERROR)), + ) + .with_state(app_state) + .fallback(handle_404) + .fallback(handle_405) + .into_make_service(); + + start_server(app, &server_config).await +} + +fn get_authed_routes(app_state: AppState) -> Router { + Router::new() + .route( + "/vaults/:vault_id/documents", + get(fetch_latest_documents::fetch_latest_documents), + ) + .route( + "/vaults/:vault_id/documents", + post(create_document::create_document), + ) + .route( + "/vaults/:vault_id/documents/:document_id", + get(fetch_latest_document_version::fetch_latest_document_version), + ) + .route( + "/vaults/:vault_id/documents/:document_id", + put(update_document::update_document), + ) + .route( + "/vaults/:vault_id/documents/:document_id/versions/:version_id", + put(fetch_document_version::fetch_document_version), + ) + .route( + "/vaults/:vault_id/documents/:document_id/versions/:version_id/content", + put(fetch_document_version_content::fetch_document_version_content), + ) + .route( + "/vaults/:vault_id/documents/:document_id", + delete(delete_document::delete_document), + ) + .layer(middleware::from_fn_with_state(app_state, auth_middleware)) +} + +async fn start_server(app: IntoMakeService, config: &ServerConfig) -> Result<()> { + let address = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(address.clone()) + .await + .with_context(|| format!("Failed to bind to address: {address}"))?; + + info!( + "Listening on http://{}", + listener + .local_addr() + .context("Failed to get local address")? + ); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .tcp_nodelay(true) + .await + .context("Failed to start server") +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } +} + +async fn handle_404() -> impl IntoResponse { + not_found_error(anyhow!("Page not found")) +} + +async fn handle_405() -> impl IntoResponse { + client_error(anyhow!("Method not allowed")) +} diff --git a/sync-server/src/server/assets/index.html b/sync-server/src/server/assets/index.html new file mode 100644 index 00000000..ef9c5a6d --- /dev/null +++ b/sync-server/src/server/assets/index.html @@ -0,0 +1,9 @@ + + + + VaultLink + + +

VaultLink server

+ + diff --git a/sync-server/src/server/auth.rs b/sync-server/src/server/auth.rs new file mode 100644 index 00000000..d27c16e3 --- /dev/null +++ b/sync-server/src/server/auth.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; + +use axum::{ + extract::{Path, Request, State}, + middleware::Next, + response::Response, +}; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; +use log::info; + +use crate::{ + app_state::{AppState, database::models::VaultId}, + config::user_config::{AllowListedVaults, User, VaultAccess}, + errors::{SyncServerError, permission_denied_error, unauthenticated_error}, + utils::normalize::normalize_string, +}; + +pub async fn auth_middleware( + State(state): State, + Path(path_params): Path>, + TypedHeader(auth_header): TypedHeader>, + mut req: Request, + next: Next, +) -> Result { + let token = auth_header.token().trim(); + let vault_id = normalize_string( + path_params + .get("vault_id") + .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing vault_id")))?, + ); + + let user = auth(&state, token, &vault_id)?; + + req.extensions_mut().insert(user); + + Ok(next.run(req).await) +} + +pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result { + let user = state + .config + .users + .get_user(token) + .cloned() + .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Invalid token")))?; + + if match user.vault_access { + VaultAccess::AllowAccessToAll => true, + VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id), + } { + info!( + "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.name + ); + + Err(permission_denied_error(anyhow::anyhow!( + "Permission denied for vault `{vault_id}`" + ))) + } +} diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs new file mode 100644 index 00000000..7018d8cf --- /dev/null +++ b/sync-server/src/server/create_document.rs @@ -0,0 +1,95 @@ +use anyhow::Context as _; +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use axum_extra::TypedHeader; +use axum_typed_multipart::TypedMultipart; +use serde::Deserialize; + +use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; +use crate::{ + app_state::{ + AppState, + database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, + }, + config::user_config::User, + errors::{SyncServerError, client_error, server_error}, + utils::{normalize::normalize, sanitize_path::sanitize_path}, +}; + +#[derive(Deserialize)] +pub struct CreateDocumentPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, +} + +/// Create a new document in case a document with the same doesn't exist +/// already. If a document with the same path exists, a new version is created +/// with their content merged. +#[axum::debug_handler] +pub async fn create_document( + Path(CreateDocumentPathParams { vault_id }): Path, + Extension(user): Extension, + TypedHeader(device_id): TypedHeader, + State(state): State, + TypedMultipart(request): TypedMultipart, +) -> Result, SyncServerError> { + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(server_error)?; + + let document_id = match request.document_id { + Some(document_id) => { + let existing_version = state + .database + .get_latest_document(&vault_id, &document_id, Some(&mut transaction)) + .await + .map_err(server_error)?; + + if existing_version.is_some() { + return Err(client_error(anyhow::anyhow!( + "Document with the same ID already exists" + ))); + } + + document_id + } + None => uuid::Uuid::new_v4(), + }; + + let last_update_id = state + .database + .get_max_update_id_in_vault(&vault_id, Some(&mut transaction)) + .await + .map_err(server_error)?; + + let sanitized_relative_path = sanitize_path(&request.relative_path); + + let new_version = StoredDocumentVersion { + vault_update_id: last_update_id + 1, + document_id, + relative_path: sanitized_relative_path, + content: request.content.contents.to_vec(), + updated_date: chrono::Utc::now(), + is_deleted: false, + user_id: user.name, + device_id: device_id.0, + }; + + state + .database + .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .await + .map_err(server_error)?; + + transaction + .commit() + .await + .context("Failed to commit successful transaction") + .map_err(server_error)?; + + Ok(Json(new_version.into())) +} diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs new file mode 100644 index 00000000..5b7cd6ef --- /dev/null +++ b/sync-server/src/server/delete_document.rs @@ -0,0 +1,84 @@ +use anyhow::Context as _; +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use axum_extra::TypedHeader; +use serde::Deserialize; + +use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; +use crate::{ + app_state::{ + AppState, + database::models::{ + DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, + }, + }, + config::user_config::User, + errors::{SyncServerError, server_error}, + utils::{normalize::normalize, sanitize_path::sanitize_path}, +}; + +#[derive(Deserialize)] +pub struct DeleteDocumentPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, + + document_id: DocumentId, +} + +#[axum::debug_handler] +pub async fn delete_document( + Path(DeleteDocumentPathParams { + vault_id, + document_id, + }): Path, + Extension(user): Extension, + TypedHeader(device_id): TypedHeader, + State(state): State, + Json(request): Json, +) -> Result, SyncServerError> { + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(server_error)?; + + let last_update_id = state + .database + .get_max_update_id_in_vault(&vault_id, Some(&mut transaction)) + .await + .map_err(server_error)?; + + let latest_content = 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 + + let new_version = StoredDocumentVersion { + vault_update_id: last_update_id + 1, + document_id, + relative_path: sanitize_path(&request.relative_path), + content: latest_content, // copy the content from the latest version + updated_date: chrono::Utc::now(), + is_deleted: true, + user_id: user.name, + device_id: device_id.0, + }; + + state + .database + .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .await + .map_err(server_error)?; + + transaction + .commit() + .await + .context("Failed to commit successful transaction") + .map_err(server_error)?; + + Ok(Json(new_version.into())) +} diff --git a/sync-server/src/server/device_id_header.rs b/sync-server/src/server/device_id_header.rs new file mode 100644 index 00000000..af9d6413 --- /dev/null +++ b/sync-server/src/server/device_id_header.rs @@ -0,0 +1,35 @@ +use axum_extra::headers; +use headers::{Header, HeaderName, HeaderValue}; + +pub struct DeviceIdHeader(pub String); + +pub static DEVICE_ID_HEADER_NAME: HeaderName = HeaderName::from_static("device-id"); + +impl Header for DeviceIdHeader { + fn name() -> &'static HeaderName { + &DEVICE_ID_HEADER_NAME + } + + fn decode<'i, I>(values: &mut I) -> Result + where + I: Iterator, + { + let value = values.next().ok_or_else(headers::Error::invalid)?; + + Ok(DeviceIdHeader( + value + .to_str() + .map_err(|_| headers::Error::invalid())? + .to_owned(), + )) + } + + fn encode(&self, values: &mut E) + where + E: Extend, + { + let value = HeaderValue::from_static(Box::leak(self.0.clone().into_boxed_str())); + + values.extend(std::iter::once(value)); + } +} diff --git a/sync-server/src/server/fetch_document_version.rs b/sync-server/src/server/fetch_document_version.rs new file mode 100644 index 00000000..5b571a7b --- /dev/null +++ b/sync-server/src/server/fetch_document_version.rs @@ -0,0 +1,57 @@ +use anyhow::anyhow; +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::Deserialize; + +use crate::{ + app_state::{ + AppState, + database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId}, + }, + errors::{SyncServerError, not_found_error, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct FetchDocumentVersionPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, + + document_id: DocumentId, + vault_update_id: VaultUpdateId, +} + +#[axum::debug_handler] +pub async fn fetch_document_version( + Path(FetchDocumentVersionPathParams { + vault_id, + document_id, + vault_update_id, + }): Path, + State(state): State, +) -> Result, SyncServerError> { + let result = state + .database + .get_document_version(&vault_id, vault_update_id, None) + .await + .map_err(server_error)? + .map_or_else( + || { + Err(not_found_error(anyhow!( + "Document with vault update id `{vault_update_id}` not found", + ))) + }, + Ok, + )?; + + 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}`", + ))); + } + + Ok(Json(result.into())) +} diff --git a/sync-server/src/server/fetch_document_version_content.rs b/sync-server/src/server/fetch_document_version_content.rs new file mode 100644 index 00000000..a419b7bf --- /dev/null +++ b/sync-server/src/server/fetch_document_version_content.rs @@ -0,0 +1,57 @@ +use anyhow::anyhow; +use axum::{ + body::Bytes, + extract::{Path, State}, +}; +use serde::Deserialize; + +use crate::{ + app_state::{ + AppState, + database::models::{DocumentId, VaultId, VaultUpdateId}, + }, + errors::{SyncServerError, not_found_error, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct FetchDocumentVersionContentPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, + + document_id: DocumentId, + vault_update_id: VaultUpdateId, +} + +#[axum::debug_handler] +pub async fn fetch_document_version_content( + Path(FetchDocumentVersionContentPathParams { + vault_id, + document_id, + vault_update_id, + }): Path, + State(state): State, +) -> Result { + let result = state + .database + .get_document_version(&vault_id, vault_update_id, None) + .await + .map_err(server_error)? + .map_or_else( + || { + Err(not_found_error(anyhow!( + "Document with vault update id `{vault_update_id}` not found", + ))) + }, + Ok, + )?; + + 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}`", + ))); + } + + Ok(result.content.into()) +} diff --git a/sync-server/src/server/fetch_latest_document_version.rs b/sync-server/src/server/fetch_latest_document_version.rs new file mode 100644 index 00000000..07f07860 --- /dev/null +++ b/sync-server/src/server/fetch_latest_document_version.rs @@ -0,0 +1,48 @@ +use anyhow::anyhow; +use axum::{ + Json, + extract::{Path, State}, +}; +use serde::Deserialize; + +use crate::{ + app_state::{ + AppState, + database::models::{DocumentId, DocumentVersion, VaultId}, + }, + errors::{SyncServerError, not_found_error, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct FetchLatestDocumentVersionPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, + + document_id: DocumentId, +} + +#[axum::debug_handler] +pub async fn fetch_latest_document_version( + Path(FetchLatestDocumentVersionPathParams { + vault_id, + document_id, + }): Path, + State(state): State, +) -> Result, SyncServerError> { + let latest_version = state + .database + .get_latest_document(&vault_id, &document_id, None) + .await + .map_err(server_error)? + .map_or_else( + || { + Err(not_found_error(anyhow!( + "Document with id `{document_id}` not found", + ))) + }, + Ok, + )?; + + Ok(Json(latest_version.into())) +} diff --git a/sync-server/src/server/fetch_latest_documents.rs b/sync-server/src/server/fetch_latest_documents.rs new file mode 100644 index 00000000..6101f55c --- /dev/null +++ b/sync-server/src/server/fetch_latest_documents.rs @@ -0,0 +1,56 @@ +use axum::{ + Json, + extract::{Path, Query, State}, +}; +use serde::Deserialize; + +use super::responses::FetchLatestDocumentsResponse; +use crate::{ + app_state::{ + AppState, + database::models::{VaultId, VaultUpdateId}, + }, + errors::{SyncServerError, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct FetchLatestDocumentsPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, +} + +#[derive(Deserialize)] +pub struct QueryParams { + since_update_id: Option, +} + +#[axum::debug_handler] +pub async fn fetch_latest_documents( + Path(FetchLatestDocumentsPathParams { vault_id }): Path, + Query(QueryParams { since_update_id }): Query, + State(state): State, +) -> Result, SyncServerError> { + let documents = if let Some(since_update_id) = since_update_id { + state + .database + .get_latest_documents_since(&vault_id, since_update_id, None) + .await + .map_err(server_error) + } else { + state + .database + .get_latest_documents(&vault_id, None) + .await + .map_err(server_error) + }?; + + Ok(Json(FetchLatestDocumentsResponse { + last_update_id: documents + .iter() + .map(|doc| doc.vault_update_id) + .max() + .unwrap_or(since_update_id.unwrap_or(0)), + latest_documents: documents, + })) +} diff --git a/sync-server/src/server/index.rs b/sync-server/src/server/index.rs new file mode 100644 index 00000000..64b053f7 --- /dev/null +++ b/sync-server/src/server/index.rs @@ -0,0 +1,7 @@ +use axum::response::{Html, IntoResponse}; + +pub async fn index() -> impl IntoResponse { + const HTML_CONTENT: &str = include_str!("./assets/index.html"); + let html_content = HTML_CONTENT; + Html(html_content) +} diff --git a/sync-server/src/server/ping.rs b/sync-server/src/server/ping.rs new file mode 100644 index 00000000..620ef0d4 --- /dev/null +++ b/sync-server/src/server/ping.rs @@ -0,0 +1,37 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use axum_extra::{ + TypedHeader, + headers::{Authorization, authorization::Bearer}, +}; +use serde::Deserialize; + +use super::{auth::auth, responses::PingResponse}; +use crate::{ + app_state::{AppState, database::models::VaultId}, + errors::SyncServerError, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct PingPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, +} + +#[axum::debug_handler] +pub async fn ping( + maybe_auth_header: Option>>, + Path(PingPathParams { vault_id }): Path, + State(state): State, +) -> Result, SyncServerError> { + 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, + })) +} diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs new file mode 100644 index 00000000..9d1e478b --- /dev/null +++ b/sync-server/src/server/requests.rs @@ -0,0 +1,39 @@ +use axum::body::Bytes; +use axum_typed_multipart::{FieldData, TryFromMultipart}; +use serde::{self, Deserialize}; +use ts_rs::TS; + +use crate::app_state::database::models::{DocumentId, VaultUpdateId}; + +#[derive(TS, Debug, TryFromMultipart)] +#[ts(export)] +pub struct 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. + pub document_id: Option, + pub relative_path: String, + + #[ts(as = "Vec")] + #[form_data(limit = "unlimited")] + pub content: FieldData, +} + +#[derive(TS, Debug, TryFromMultipart)] +#[ts(export)] +pub struct UpdateDocumentVersion { + 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 DeleteDocumentVersion { + pub relative_path: String, +} diff --git a/sync-server/src/server/responses.rs b/sync-server/src/server/responses.rs new file mode 100644 index 00000000..5cfaa5d5 --- /dev/null +++ b/sync-server/src/server/responses.rs @@ -0,0 +1,45 @@ +use serde::{self, Serialize}; +use ts_rs::TS; + +use crate::app_state::database::models::{ + DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId, +}; + +/// Response to a ping request. +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct PingResponse { + /// Semantic version of the server. + pub server_version: String, + + /// Whether the client is authenticated based on the sent Authorization + /// header. + pub is_authenticated: bool, +} + +/// Response to a fetch latest documents request. +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct FetchLatestDocumentsResponse { + pub latest_documents: Vec, + + /// The update ID of the latest document in the response. + pub last_update_id: VaultUpdateId, +} + +/// Response to an update document request. +#[derive(TS, Debug, Clone, Serialize)] +#[serde(tag = "type")] +#[ts(export)] +pub enum DocumentUpdateResponse { + /// Returned when the created/updated document's content is the same as was + /// sent in the create/update request and thus the response doesn't contain + /// the content because the client must already have it. + FastForwardUpdate(DocumentVersionWithoutContent), + + /// Returned when the created/updated document's content is different from + /// what was sent in the create/update request. + MergingUpdate(DocumentVersion), +} diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs new file mode 100644 index 00000000..99d3f490 --- /dev/null +++ b/sync-server/src/server/update_document.rs @@ -0,0 +1,199 @@ +use anyhow::{Context as _, anyhow}; +use axum::{ + Extension, Json, + extract::{Path, State}, +}; +use axum_extra::TypedHeader; +use axum_typed_multipart::TypedMultipart; +use log::info; +use reconcile_text::{BuiltinTokenizer, is_binary, reconcile}; +use serde::Deserialize; + +use super::{ + device_id_header::DeviceIdHeader, requests::UpdateDocumentVersion, + responses::DocumentUpdateResponse, +}; +use crate::{ + app_state::{ + AppState, + database::models::{DocumentId, StoredDocumentVersion, VaultId}, + }, + config::user_config::User, + errors::{SyncServerError, not_found_error, server_error}, + utils::{ + dedup_paths::dedup_paths, is_file_type_mergable::is_file_type_mergable, + normalize::normalize, sanitize_path::sanitize_path, + }, +}; + +#[derive(Deserialize)] +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( + Path(UpdateDocumentPathParams { + vault_id, + document_id, + }): Path, + Extension(user): Extension, + TypedHeader(device_id): TypedHeader, + State(state): State, + TypedMultipart(request): TypedMultipart, +) -> Result, SyncServerError> { + // No need for a transaction as document versions are immutable + let parent_document = state + .database + .get_document_version(&vault_id, request.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 + ))) + }, + Ok, + )?; + + let sanitized_relative_path = sanitize_path(&request.relative_path); + + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(server_error)?; + + let last_update_id = state + .database + .get_max_update_id_in_vault(&vault_id, Some(&mut transaction)) + .await + .map_err(server_error)?; + + let latest_version = state + .database + .get_latest_document(&vault_id, &document_id, Some(&mut transaction)) + .await + .map_err(server_error)? + .map_or_else( + || { + Err(not_found_error(anyhow!( + "Document with id `{document_id}` not found", + ))) + }, + Ok, + )?; + + if latest_version.is_deleted { + transaction + .rollback() + .await + .context("Failed to roll back transaction") + .map_err(server_error)?; + + 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"); + transaction + .rollback() + .await + .context("Failed to roll back transaction") + .map_err(server_error)?; + + return Ok(Json(DocumentUpdateResponse::FastForwardUpdate( + latest_version.into(), + ))); + } + + let merged_content = if is_file_type_mergable(&sanitized_relative_path) + && !is_binary(&parent_document.content) + && !is_binary(&latest_version.content) + && !is_binary(&content) + { + reconcile( + str::from_utf8(&parent_document.content) + .expect("parent must be valid UTF-8 because it's not binary"), + &str::from_utf8(&latest_version.content) + .expect("latest_version must be valid UTF-8 because it's not binary") + .into(), + &str::from_utf8(&content) + .expect("content must be valid UTF-8 because it's not binary") + .into(), + &*BuiltinTokenizer::Word, + ) + .apply() + .text() + .into_bytes() + } else { + content.clone() + }; + + let is_different_from_request_content = merged_content != content; + + // We can only update the relative path if we're the first one to do so + 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; + } + } + + new_relative_path + } else { + latest_version.relative_path.clone() + }; + + let new_version = StoredDocumentVersion { + document_id, + vault_update_id: last_update_id + 1, + relative_path: new_relative_path, + content: merged_content, + updated_date: chrono::Utc::now(), + is_deleted: false, + user_id: user.name, + device_id: device_id.0, + }; + + state + .database + .insert_document_version(&vault_id, &new_version, Some(&mut transaction)) + .await + .map_err(server_error)?; + + transaction + .commit() + .await + .context("Failed to commit successful transaction") + .map_err(server_error)?; + + Ok(Json(if is_different_from_request_content { + DocumentUpdateResponse::MergingUpdate(new_version.into()) + } else { + DocumentUpdateResponse::FastForwardUpdate(new_version.into()) + })) +} diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs new file mode 100644 index 00000000..0e4f705f --- /dev/null +++ b/sync-server/src/server/websocket.rs @@ -0,0 +1,181 @@ +use anyhow::Context; +use axum::{ + extract::{ + Path, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::Response, +}; +use futures::stream::StreamExt; +use log::{debug, info}; +use serde::Deserialize; + +use crate::{ + app_state::{ + AppState, + database::models::VaultId, + websocket::{ + models::{ + CursorPositionFromServer, WebSocketClientMessage, WebSocketServerMessage, + WebSocketVaultUpdate, + }, + utils::{ + get_authenticated_handshake, get_unseen_documents, send_update_over_websocket, + }, + }, + }, + errors::{SyncServerError, client_error, server_error}, + utils::normalize::normalize, +}; + +#[derive(Deserialize)] +pub struct WebSocketPathParams { + #[serde(deserialize_with = "normalize")] + vault_id: VaultId, +} + +pub async fn websocket_handler( + ws: WebSocketUpgrade, + Path(WebSocketPathParams { vault_id }): Path, + State(state): State, +) -> Result { + Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id))) +} + +async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId) { + 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}"); + } +} + +#[allow(clippy::too_many_lines)] +async fn websocket( + state: AppState, + stream: WebSocket, + vault_id: VaultId, +) -> Result<(), SyncServerError> { + let (mut sender, mut websocket_receiver) = stream.split(); + + let authed_handshake = get_authenticated_handshake( + &state, + &vault_id, + websocket_receiver + .next() + .await + .transpose() + .unwrap_or_default(), + )?; + + info!( + "WebSocket handshake successful for vault '{vault_id}' for '{}'", + authed_handshake.handshake.device_id + ); + + let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; + + send_update_over_websocket( + &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: get_unseen_documents( + &state, + &vault_id, + authed_handshake.handshake.last_seen_vault_update_id, + ) + .await?, + is_initial_sync: true, + }), + &mut sender, + ) + .await?; + + send_update_over_websocket( + &WebSocketServerMessage::CursorPositions(CursorPositionFromServer { + clients: state.cursors.get_cursors(&vault_id).await, + }), + &mut sender, + ) + .await?; + + let device_id = authed_handshake.handshake.device_id.clone(); + let mut send_task = tokio::spawn(async move { + while let Ok(update) = broadcast_receiver.recv().await { + if Some(&device_id) == update.origin_device_id.as_ref() { + continue; + } + + send_update_over_websocket(&update.message, &mut sender).await?; + } + + Ok::<(), SyncServerError>(()) + }); + + let device_id = authed_handshake.handshake.device_id.clone(); + let vault_id_clone = vault_id.clone(); + let cursor_manager = state.cursors.clone(); + let mut receive_task = tokio::spawn(async move { + while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse WebSocket message from client") + .map_err(server_error)?; + + match message { + WebSocketClientMessage::Handshake(_) => { + return Err(client_error(anyhow::anyhow!( + "Unexpected handshake message" + ))); + } + WebSocketClientMessage::CursorPositions(cursors) => { + cursor_manager + .update_cursors( + vault_id_clone.clone(), + authed_handshake.user.name.clone(), + &device_id, + cursors.documents_with_cursors, + ) + .await; + } + } + } + + Ok::<(), SyncServerError>(()) + }); + + tokio::select! { + _ = &mut send_task => receive_task.abort(), + _ = &mut receive_task => send_task.abort(), + }; + + let result: Result<(), SyncServerError> = (async { + send_task + .await + .context("WebSocket send task failed") + .map_err(client_error) + .and_then(|err| err)?; + + receive_task + .await + .context("WebSocket receive task failed") + .map_err(client_error) + .and_then(|err| err)?; + + Ok(()) + }) + .await; + + state + .cursors + .remove_cursors_of_device(&vault_id, &authed_handshake.handshake.device_id) + .await; + + if result.is_err() { + info!( + "WebSocket disconnected on vault '{vault_id}' for '{}'", + authed_handshake.handshake.device_id + ); + } + + result +} diff --git a/sync-server/src/utils.rs b/sync-server/src/utils.rs new file mode 100644 index 00000000..010524de --- /dev/null +++ b/sync-server/src/utils.rs @@ -0,0 +1,4 @@ +pub mod dedup_paths; +pub mod is_file_type_mergable; +pub mod normalize; +pub mod sanitize_path; diff --git a/sync-server/src/utils/dedup_paths.rs b/sync-server/src/utils/dedup_paths.rs new file mode 100644 index 00000000..c35ad33b --- /dev/null +++ b/sync-server/src/utils/dedup_paths.rs @@ -0,0 +1,88 @@ +use regex::Regex; + +pub fn dedup_paths(path: &str) -> impl Iterator { + let mut path_parts = path.split('/').collect::>(); + let file_name = path_parts.pop().unwrap().to_owned(); + + let mut directory = path_parts.join("/"); + if !directory.is_empty() { + 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"), + }; + + let regex = Regex::new(r" \((\d+)\)$").unwrap(); + let start_number = regex + .captures(&stem) + .and_then(|caps| caps.get(1)) + .and_then(|m| m.as_str().parse::().ok()) + .unwrap_or(0); + + let clean_stem = regex.replace(&stem, "").to_string(); + + (start_number..).map(move |dedup_number| { + if dedup_number == 0 { + format!("{directory}{clean_stem}{extension}") + } else { + format!("{directory}{clean_stem} ({dedup_number}){extension}") + } + }) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_dedup_paths() { + let mut deduped = dedup_paths("file.txt"); + assert_eq!(deduped.next(), Some("file.txt".to_owned())); + assert_eq!(deduped.next(), Some("file (1).txt".to_owned())); + assert_eq!(deduped.next(), Some("file (2).txt".to_owned())); + + let mut deduped = dedup_paths("file"); + assert_eq!(deduped.next(), Some("file".to_owned())); + assert_eq!(deduped.next(), Some("file (1)".to_owned())); + assert_eq!(deduped.next(), Some("file (2)".to_owned())); + + let mut deduped = dedup_paths("file (51).md"); + assert_eq!(deduped.next(), Some("file (51).md".to_owned())); + assert_eq!(deduped.next(), Some("file (52).md".to_owned())); + assert_eq!(deduped.next(), Some("file (53).md".to_owned())); + + let mut deduped = dedup_paths("file (5)"); + assert_eq!(deduped.next(), Some("file (5)".to_owned())); + assert_eq!(deduped.next(), Some("file (6)".to_owned())); + assert_eq!(deduped.next(), Some("file (7)".to_owned())); + + let mut deduped = dedup_paths("my/path.with.dots/file (5).md"); + assert_eq!( + deduped.next(), + Some("my/path.with.dots/file (5).md".to_owned()) + ); + assert_eq!( + deduped.next(), + Some("my/path.with.dots/file (6).md".to_owned()) + ); + + let mut deduped = dedup_paths("my/path.with.dots/file (5)"); + assert_eq!( + deduped.next(), + Some("my/path.with.dots/file (5)".to_owned()) + ); + assert_eq!( + deduped.next(), + Some("my/path.with.dots/file (6)".to_owned()) + ); + } +} diff --git a/sync-server/src/utils/is_file_type_mergable.rs b/sync-server/src/utils/is_file_type_mergable.rs new file mode 100644 index 00000000..fba4b323 --- /dev/null +++ b/sync-server/src/utils/is_file_type_mergable.rs @@ -0,0 +1,23 @@ +pub fn is_file_type_mergable(path_or_file_name: &str) -> bool { + let file_extension = path_or_file_name.split('.').next_back().unwrap_or_default(); + + matches!(file_extension.to_lowercase().as_str(), "md" | "txt") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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")); + + assert!(!is_file_type_mergable(".json")); + assert!(!is_file_type_mergable("HELLO.JSON")); + assert!(!is_file_type_mergable("my/config.yml")); + } +} diff --git a/sync-server/src/utils/normalize.rs b/sync-server/src/utils/normalize.rs new file mode 100644 index 00000000..6553dd25 --- /dev/null +++ b/sync-server/src/utils/normalize.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Deserializer}; + +pub fn normalize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + Ok(normalize_string(&s)) +} + +pub fn normalize_string(s: &str) -> String { + s.trim().to_lowercase() +} diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs new file mode 100644 index 00000000..9703225c --- /dev/null +++ b/sync-server/src/utils/sanitize_path.rs @@ -0,0 +1,34 @@ +/// Sanitize the document's path to allow all clients to create the same path in +/// their filesystem. If we didn't do this server-side, client's would need to +/// deal with mapping invalid names to valid ones and then back. +pub fn sanitize_path(path: &str) -> String { + let options = sanitize_filename::Options { + truncate: true, + windows: true, // Windows is the lowest common denominator + replacement: "", + }; + + path.split('/') + .map(|part| { + let proposal = sanitize_filename::sanitize_with_options(part, options.clone()); + if !part.is_empty() && proposal.is_empty() { + "_".to_owned() + } else { + proposal + } + }) + .collect::>() + .join("/") +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_sanitize_path() { + assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what"); + assert_eq!(sanitize_path("file (1).md"), "file (1).md"); + assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_"); + } +}