Compare commits

...
Sign in to create a new pull request.

26 commits

Author SHA1 Message Date
6ea7d53a49 Migrate to using taskfiles 2026-01-12 22:42:09 +00:00
0e1849061b Improve DB contention 2026-01-12 21:24:18 +00:00
2dfb8b71e5 Working setup 2026-01-12 21:24:05 +00:00
e3a90833ff Lint 2026-01-04 14:14:05 +00:00
7c991c3b4d Fix syncing logic 2026-01-04 14:08:33 +00:00
0d7d36e971 Formatting & small fixes 2026-01-04 11:02:15 +00:00
951200724c Merge branch 'main' into asch/smart-create 2025-12-16 21:02:07 +00:00
c4f992c9d6 wip 2025-12-14 23:30:04 +00:00
e103bba12c Don't depend on uuid 2025-12-14 17:09:39 +00:00
439c066b57 Use rust 1.92 2025-12-14 17:08:04 +00:00
63867be48a Remove ws dep 2025-12-14 14:39:16 +00:00
a21b1e8c03 Extract errors into module 2025-12-14 14:37:30 +00:00
d13abc115d Format without eclint 2025-12-14 14:14:07 +00:00
7438108885 Bumps 2025-12-14 14:08:48 +00:00
a212aba755 Use node 25 2025-12-14 13:58:36 +00:00
16bb5042d5 Format & lint 2025-12-14 13:55:23 +00:00
e25306c4c1 Check node version 2025-12-14 13:53:35 +00:00
c7507a3e7a Upload logs instead of printing them 2025-12-14 11:47:47 +00:00
f431bea1af Add lock tests 2025-12-14 11:43:57 +00:00
d91993f249 Unsubscribe in SyncClient 2025-12-14 11:31:48 +00:00
45505a4bf7 Wait for idle instead 2025-12-14 11:06:49 +00:00
9c5882e5fb Handle websocket race condition 2025-12-14 11:05:55 +00:00
19022c5b5f Reject pending locks on reset 2025-12-14 11:05:36 +00:00
2a53fd3b59 Don't publish PRs 2025-12-14 10:55:54 +00:00
c638ded53a Always kill server 2025-12-14 10:55:46 +00:00
63a2079773 Extract const 2025-12-13 12:03:35 +00:00
91 changed files with 5176 additions and 8107 deletions

View file

@ -23,14 +23,19 @@ jobs:
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.2.0
with: with:
node-version: "22.x" node-version: "25.x"
check-latest: true check-latest: true
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: "1.89.0" toolchain: "1.92.0"
components: clippy, rustfmt components: clippy, rustfmt
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
- name: Lint & test - name: Lint & test
run: scripts/check.sh run: task check

View file

@ -5,8 +5,8 @@ on:
branches: branches:
- main - main
paths: paths:
- 'docs/**' - "docs/**"
- '.github/workflows/deploy-docs.yml' - ".github/workflows/deploy-docs.yml"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@ -28,18 +28,22 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node - name: Setup Node.js environment
uses: actions/setup-node@v4 uses: actions/setup-node@v4.2.0
with: with:
node-version: 22 node-version: "25.x"
cache: npm check-latest: true
cache-dependency-path: docs/package-lock.json
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v4 uses: actions/configure-pages@v4
- name: Build docs - name: Build docs
run: scripts/build-docs.sh run: task docs:check
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3

View file

@ -6,7 +6,7 @@ on:
pull_request: pull_request:
branches: ["main"] branches: ["main"]
schedule: schedule:
- cron: '0 * * * *' - cron: "0 * * * *"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@ -28,21 +28,22 @@ jobs:
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.2.0
with: with:
node-version: "22.x" node-version: "25.x"
check-latest: true check-latest: true
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: "1.89.0" toolchain: "1.92.0"
components: clippy, rustfmt components: clippy, rustfmt
- name: Setup rust - name: Install Task
run: | uses: arduino/setup-task@v2
which sqlx || cargo install sqlx-cli with:
cd sync-server version: 3.x
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 - name: Setup database
run: task db:setup
- name: E2E tests - name: E2E tests
run: | run: |
@ -51,7 +52,7 @@ jobs:
SERVER_PID=$! SERVER_PID=$!
cd .. cd ..
scripts/e2e.sh 8 task e2e -- 8
EXIT_CODE=$? EXIT_CODE=$?
kill $SERVER_PID 2>/dev/null || true kill $SERVER_PID 2>/dev/null || true
@ -69,4 +70,4 @@ jobs:
- name: Cleanup - name: Cleanup
if: always() if: always()
run: scripts/clean-up.sh run: task clean

View file

@ -19,28 +19,30 @@ jobs:
- name: Setup Node.js environment - name: Setup Node.js environment
uses: actions/setup-node@v4.2.0 uses: actions/setup-node@v4.2.0
with: with:
node-version: "22.x" node-version: "25.x"
check-latest: true check-latest: true
- name: Build plugin
run: |
cd frontend
npm ci
npm run build
- name: Setup Rust toolchain - name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: "1.89.0" toolchain: "1.92.0"
components: clippy, rustfmt components: clippy, rustfmt
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
- name: Install cross-compilation tools - name: Install cross-compilation tools
run: | run: |
apt update apt update
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
- name: Build Linux and Windows binaries - name: Build frontend
run: ./scripts/build-sync-server-binaries.sh run: task frontend:build
- name: Build binaries
run: task release:build-binaries
- name: Create release - name: Create release
env: env:

View file

@ -5,6 +5,6 @@
"**/dist": true, "**/dist": true,
"**/node_modules": true, "**/node_modules": true,
"**/.sqlx": true, "**/.sqlx": true,
"**/target": true, "**/target": true
}, }
} }

240
CLAUDE.md
View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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. 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 four main components: an Obsidian plugin, a sync client library, a test client, and a standalone CLI client.
## Architecture ## Architecture
@ -13,98 +13,234 @@ VaultLink is a self-hosted Obsidian plugin for real-time collaborative file sync
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization - **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/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/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
- **frontend/test-client/**: CLI testing tool for the sync functionality - **frontend/test-client/**: CLI testing tool for simulating multiple concurrent users
- **frontend/local-client-cli/**: Standalone CLI for VaultLink sync client
### Key Technologies ### Key Technologies
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync - **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing - **Frontend**: TypeScript, Webpack for bundling, Node.js native test runner
- **Sync Algorithm**: Uses reconcile-text library for operational transformation - **Sync Algorithm**: Uses reconcile-text library for operational transformation
### Architectural Patterns
**Server Architecture:**
- `AppState`: Central state container holding `Database`, `Cursors`, and `Broadcasts`
- `Database`: SQLite-backed document versioning with SQLx for compile-time query verification
- `Broadcasts`: WebSocket broadcast system for real-time updates to connected clients
- `Cursors`: Tracks user cursor positions across documents with background cleanup task
**Client Architecture:**
- `SyncClient`: Main entry point, orchestrates all sync operations
- `SyncService`: HTTP API client for CRUD operations on documents
- `WebSocketManager`: Manages WebSocket connection and real-time updates
- `Syncer`: Coordinates file synchronization between local filesystem and server
- `CursorTracker`: Manages local and remote cursor positions
- `Database`: Client-side document metadata cache
- `FileOperations`: Abstraction layer for filesystem operations
**Dual-Bundle Strategy:**
The sync-client builds two separate bundles:
- `sync-client.web.js`: Browser-compatible UMD bundle (excludes `ws` package)
- `sync-client.node.js`: Node.js CommonJS bundle with WebSocket support
## Development Commands ## Development Commands
### Server Development This project uses [Taskfile](https://taskfile.dev/) for task automation. Run `task --list` to see all available tasks.
### Initial Setup
**Taskfile:**
```bash
# Install Task (https://taskfile.dev/installation/)
# macOS
brew install go-task
# Linux
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin
# Or via npm
npm install -g @go-task/cli
```
**Node.js (requires version 25):**
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
nvm install 25
nvm use 25
nvm alias default 25 # Optional: set as system default
```
**Rust:**
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
cargo install sqlx-cli cargo-machete cargo-edit cargo-insta
```
**Frontend:**
```bash
task frontend:install
```
### Common Tasks (Taskfile)
```bash
task check # Full CI check (lint, test, format). Run before pushing.
task check:fix # Same as above but auto-fixes issues
task e2e -- 8 # E2E tests with 8 concurrent clients
task clean # Clean logs and database files
task update-api-types # Update TypeScript bindings from Rust types
task release:bump -- patch # Bump version (patch|minor|major)
```
### Server Tasks
```bash
task rust:run # Start development server
task rust:test # Run all Rust tests
task rust:clippy # Lint Rust code
task rust:clippy-fix # Auto-fix clippy warnings
task rust:fmt # Format Rust code
task rust:fmt-check # Check Rust formatting
task rust:machete # Detect unused dependencies
```
### Frontend Tasks
```bash
task frontend:dev # Start development mode
task frontend:build # Build all workspaces
task frontend:test # Run all frontend tests
task frontend:lint # Lint and format TypeScript
```
### Database Tasks
```bash
task db:setup # Create and migrate database
task db:reset # Reset database (delete and recreate)
task db:prepare # Prepare SQLx offline data
task db:add-migration NAME=<migration_name> # Add new migration
```
### Documentation Tasks
```bash
task docs:check # Build and check documentation
task docs:dev # Start documentation dev server
```
### Direct Commands (Alternative)
If you prefer not to use Taskfile, these commands work directly:
**Server:**
```bash ```bash
cd sync-server cd sync-server
cargo run config-e2e.yml # Start development server cargo run config-e2e.yml # Start development server
cargo test --verbose # Run Rust tests cargo test --verbose # Run all Rust tests
cargo clippy --all-targets --all-features # Lint Rust code cargo clippy --all-targets --all-features # Lint
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings cargo fmt --all # Format
cargo fmt --all -- --check # Check Rust formatting
cargo fmt --all # Auto-format Rust code
cargo machete --with-metadata # Detect unused dependencies
``` ```
### Frontend Development **Frontend:**
```bash ```bash
cd frontend cd frontend
npm run dev # Start development mode (watches sync-client and obsidian-plugin) npm run dev # Development mode
npm run build # Build all workspaces npm run build # Build all workspaces
npm run test # Run all tests npm run test # Run tests
npm run lint # Lint and format TypeScript code npm run lint # Lint and format
``` ```
### Database Setup (Development) **Database:**
```bash ```bash
cd sync-server cd sync-server
sqlx database create --database-url sqlite://db.sqlite3 sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
``` ```
### Initial Setup
```bash
# Install required cargo tools
cargo install sqlx-cli cargo-machete cargo-edit
```
### Scripts
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
- `scripts/e2e.sh`: End-to-end testing
- `scripts/clean-up.sh`: Clean logs and database files
- `scripts/bump-version.sh patch`: Publish new version
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
## Code Structure ## Code Structure
### Workspace Configuration ### Workspace Configuration
The frontend uses npm workspaces with four packages: The frontend uses npm workspaces with four packages:
- `sync-client`: Core synchronization logic
- `sync-client`: Core synchronization logic (builds dual bundles for web and Node.js)
- `obsidian-plugin`: Obsidian-specific integration - `obsidian-plugin`: Obsidian-specific integration
- `test-client`: Testing utilities - `test-client`: Testing utilities for E2E tests
- `local-client-cli`: Standalone CLI for VaultLink sync client - `local-client-cli`: Standalone CLI for VaultLink sync client
### Type Generation ### Type Generation and API Updates
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
### Key Files Rust structs generate TypeScript types via ts-rs crate:
- `sync-server/src/`: Rust server implementation with WebSocket handlers
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point 1. Rust structs annotated with `#[derive(TS)]` export to `sync-server/bindings/`
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class 2. Run `task update-api-types` to copy bindings to `frontend/sync-client/src/services/types/`
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic 3. Frontend imports these types for type-safe API communication
### Important Implementation Details
**SQLx Compile-Time Verification:**
- SQLx verifies SQL queries at compile time against the database schema
- Run `cargo sqlx prepare --workspace` after schema changes to update `.sqlx/` directory
- CI builds require prepared query metadata to avoid needing a live database
## Testing ## Testing
### Running Tests ### Running Tests
- Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces) ```bash
- E2E: `scripts/e2e.sh` task rust:test # All Rust tests
task frontend:test # All frontend tests
task e2e -- 8 # E2E with 8 concurrent clients
task clean # Clean up after tests
```
Or use direct commands:
```bash
cd sync-server && cargo test --verbose # Rust tests
cd frontend && npm run test # Frontend tests
```
### Test Structure ### 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**: Unit tests alongside source files, uses `cargo-insta` for snapshot testing
- **TypeScript**: `.test.ts` files using Node.js native test runner (not Jest)
- **E2E**: Uses `test-client` to simulate multiple concurrent users with random operations
## Code Style and Formatting
### Rust ### Rust
- Uses extensive Clippy lints (see Cargo.toml)
- Follows pedantic linting rules - Extensive Clippy lints (see `Cargo.toml`)
- Pedantic linting rules enabled
- Forbids unsafe code - Forbids unsafe code
- Uses cargo fmt with default settings - Uses `rustfmt.toml` for formatting configuration (4 spaces, Unix line endings)
- Run `cargo fmt --all` to format
### TypeScript ### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin - **Prettier**: 4-space indentation, no trailing commas, LF line endings
- Consistent across all three frontend packages - **YAML/Markdown override**: 2-space indentation (via prettier config)
- **ESLint**: Strict rules with unused imports detection
- Configuration in `frontend/package.json`
- Run `npm run lint` to format and fix issues
### EditorConfig
- `.editorconfig` at project root defines baseline formatting rules
- `rustfmt.toml` and Prettier config explicitly mirror these settings
- Both formatters enforce: 4-space indent (2 for YAML/MD), LF endings, final newline, trim trailing whitespace

View file

@ -8,12 +8,12 @@
## Develop ## Develop
### Install [nvm](https://github.com/nvm-sh/nvm) ### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
- `nvm install 22` - `nvm install 25`
- `nvm use 22` - `nvm use 25`
- Optionally set the system-wide default: `nvm alias default 22` - Optionally, set the system-wide default: `nvm alias default 25`
### Set up Rust ### Set up Rust
@ -77,3 +77,10 @@ And to clean up the logs & database files, run `scripts/clean-up.sh`
## Projects ## Projects
- [Sync server](./sync-server/README.md) - [Sync server](./sync-server/README.md)
a create that has been processed by the server but got lost on the way back will create a 2nd doc if it gets edited

96
Taskfile.yml Normal file
View file

@ -0,0 +1,96 @@
version: "3"
vars:
NODE_VERSION: "25"
includes:
rust:
taskfile: ./taskfiles/rust.yml
dir: ./sync-server
frontend:
taskfile: ./taskfiles/frontend.yml
dir: ./frontend
db:
taskfile: ./taskfiles/database.yml
dir: ./sync-server
e2e:
taskfile: ./taskfiles/e2e.yml
docs:
taskfile: ./taskfiles/docs.yml
dir: ./docs
release:
taskfile: ./taskfiles/release.yml
tasks:
check:
desc: Run all checks (lint, test, format)
cmds:
- task: check-node
- task: db:setup
- task: rust:test
- task: rust:lint
- task: update-api-types
- task: frontend:install
- task: frontend:build
- task: frontend:test
- task: frontend:lint
- task: format
- task: check-clean
check:fix:
desc: Run all checks with auto-fix enabled
cmds:
- task: check-node
- task: db:setup
- task: rust:test
- task: rust:lint-fix
- task: update-api-types
- task: frontend:install
- task: frontend:build
- task: frontend:test
- task: frontend:lint
- task: format
check-node:
internal: true
silent: true
preconditions:
- sh: node -v | grep -q "^v{{.NODE_VERSION}}"
msg: "Node.js {{.NODE_VERSION}} required (found: $(node -v))"
cmds:
- echo "Node.js {{.NODE_VERSION}} confirmed"
check-clean:
internal: true
preconditions:
- sh: test -z "$(git status --porcelain)"
msg: |
Working directory not clean after linting:
$(git status --porcelain)
format:
desc: Format all files with Prettier
dir: "{{.ROOT_DIR}}"
cmds:
- npx -C frontend prettier --write "**/*.{ts,js,json,md,yml,yaml}"
update-api-types:
desc: Update TypeScript bindings from Rust types
cmds:
- rm -rf sync-server/bindings
- task: rust:export-bindings
- cp -r sync-server/bindings/* frontend/sync-client/src/services/types/
- cd frontend && npm run lint
- task: format
clean:
desc: Clean up logs and databases
cmds:
- rm -rf sync-server/databases logs
e2e:
desc: Run E2E tests (usage - task e2e -- 8)
cmds:
- task: e2e:run
vars:
PROCESS_COUNT: "{{.CLI_ARGS}}"

View file

@ -2,12 +2,7 @@
"version": "0.2", "version": "0.2",
"language": "en-GB", "language": "en-GB",
"dictionaries": ["en-gb"], "dictionaries": ["en-gb"],
"ignorePaths": [ "ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
"node_modules",
".vitepress/dist",
".vitepress/cache",
"package-lock.json"
],
"words": [ "words": [
"VaultLink", "VaultLink",
"Obsidian", "Obsidian",

View file

@ -361,11 +361,11 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "upload_file", "type": "upload_file",
"path": "notes/example.md", "path": "notes/example.md",
"content": "File content here...", "content": "File content here...",
"base_version": 10, "base_version": 10,
"timestamp": "2024-01-01T12:00:00Z" "timestamp": "2024-01-01T12:00:00Z"
} }
``` ```
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "download_file", "type": "download_file",
"path": "notes/example.md" "path": "notes/example.md"
} }
``` ```
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "delete_file", "type": "delete_file",
"path": "notes/old.md" "path": "notes/old.md"
} }
``` ```
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "list_files", "type": "list_files",
"since_version": 0 "since_version": 0
} }
``` ```
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_updated", "type": "file_updated",
"path": "notes/example.md", "path": "notes/example.md",
"version": 11, "version": 11,
"size": 1024, "size": 1024,
"hash": "abc123..." "hash": "abc123..."
} }
``` ```
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_content", "type": "file_content",
"path": "notes/example.md", "path": "notes/example.md",
"content": "Updated content...", "content": "Updated content...",
"version": 11 "version": 11
} }
``` ```
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "file_deleted", "type": "file_deleted",
"path": "notes/old.md", "path": "notes/old.md",
"version": 12 "version": 12
} }
``` ```
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "sync_complete", "type": "sync_complete",
"total_files": 150, "total_files": 150,
"current_version": 200 "current_version": 200
} }
``` ```
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
```json ```json
{ {
"type": "error", "type": "error",
"message": "File too large", "message": "File too large",
"code": "FILE_TOO_LARGE" "code": "FILE_TOO_LARGE"
} }
``` ```

View file

@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework.
**Technology**: **Technology**:
- **Language**: Rust 1.89+ - **Language**: Rust 1.92+
- **Framework**: Axum (async web framework) - **Framework**: Axum (async web framework)
- **Database**: SQLite with SQLx - **Database**: SQLite with SQLx
- **Protocol**: WebSockets for real-time communication - **Protocol**: WebSockets for real-time communication

View file

@ -243,9 +243,9 @@ users:
2. Client sends authentication message: 2. Client sends authentication message:
```json ```json
{ {
"type": "auth", "type": "auth",
"token": "user-token", "token": "user-token",
"vault": "vault-name" "vault": "vault-name"
} }
``` ```
3. Server validates: 3. Server validates:

View file

@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64
### Build from Source ### Build from Source
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
```bash ```bash
# Clone the repository # Clone the repository

5960
docs/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
FROM node:22-slim AS builder FROM node:25-slim AS builder
WORKDIR /build WORKDIR /build
@ -7,7 +7,7 @@ COPY . .
RUN npm ci RUN npm ci
RUN npm run build RUN npm run build
FROM node:22-alpine FROM node:25-alpine
LABEL org.opencontainers.image.title="VaultLink Local CLI" LABEL org.opencontainers.image.title="VaultLink Local CLI"
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client" LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"

View file

@ -47,24 +47,24 @@ vaultlink \
### Required ### Required
| Option | Description | | Option | Description |
|--------|-------------| | ------------------------- | --------------------------------------------- |
| `-l, --local-path <path>` | Local directory to sync | | `-l, --local-path <path>` | Local directory to sync |
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) | | `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
| `-t, --token <token>` | Authentication token | | `-t, --token <token>` | Authentication token |
| `-v, --vault-name <name>` | Vault name on server | | `-v, --vault-name <name>` | Vault name on server |
### Optional ### Optional
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| | ------------------------------------ | ------- | -------------------------------------- |
| `--sync-concurrency <number>` | `1` | Concurrent sync operations | | `--sync-concurrency <number>` | `1` | Concurrent sync operations |
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB | | `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) | | `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval | | `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR | | `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
| `-h, --help` | - | Show help | | `-h, --help` | - | Show help |
| `-V, --version` | - | Show version | | `-V, --version` | - | Show version |
### Auto-Ignored Patterns ### Auto-Ignored Patterns
@ -74,11 +74,13 @@ vaultlink \
### Examples ### Examples
Basic usage: Basic usage:
```bash ```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
``` ```
With ignore patterns: With ignore patterns:
```bash ```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--ignore-pattern "*.tmp" \ --ignore-pattern "*.tmp" \
@ -87,6 +89,7 @@ vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
``` ```
With debug logging: With debug logging:
```bash ```bash
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \ vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
--log-level DEBUG --log-level DEBUG
@ -176,6 +179,7 @@ services:
## Development ## Development
Build: Build:
```bash ```bash
npm run build npm run build
# or from the parent folder, run # or from the parent folder, run
@ -183,11 +187,13 @@ docker build -f local-client-cli/Dockerfile .
``` ```
Test: Test:
```bash ```bash
npm test npm test
``` ```
Docker build: Docker build:
```bash ```bash
cd frontend cd frontend
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test . docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .

View file

@ -11,18 +11,16 @@
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "tsx --test 'src/**/*.test.ts'" "test": "tsx --test 'src/**/*.test.ts'"
}, },
"dependencies": {
"commander": "^14.0.2",
"watcher": "^2.3.1"
},
"devDependencies": { "devDependencies": {
"@types/node": "^24.8.1", "commander": "^14.0.2",
"watcher": "^2.3.1",
"@types/node": "^25.0.2",
"sync-client": "file:../sync-client", "sync-client": "file:../sync-client",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.4",
"tslib": "2.8.1", "tslib": "2.8.1",
"tsx": "^4.20.6", "tsx": "^4.21.0",
"typescript": "5.8.3", "typescript": "5.9.3",
"webpack": "^5.99.9", "webpack": "^5.103.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
} }

View file

@ -106,8 +106,8 @@ export class FileWatcher {
} }
/** /**
* Convert a native platform path to forward slashes * Convert a native platform path to forward slashes
*/ */
private toUnixPath(nativePath: string): string { private toUnixPath(nativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/"); return nativePath.replace(/\\/g, "/");

View file

@ -185,8 +185,8 @@ export class NodeFileSystemOperations implements FileSystemOperations {
} }
/** /**
* Convert a forward-slash path to native platform path separators * Convert a forward-slash path to native platform path separators
*/ */
private toNativePath(relativePath: string): string { private toNativePath(relativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return relativePath.replace(/\//g, "\\"); return relativePath.replace(/\//g, "\\");
@ -195,8 +195,8 @@ export class NodeFileSystemOperations implements FileSystemOperations {
} }
/** /**
* Convert a native platform path to forward slashes * Convert a native platform path to forward slashes
*/ */
private toUnixPath(nativePath: string): string { private toUnixPath(nativePath: string): string {
if (path.sep === "\\") { if (path.sep === "\\") {
return nativePath.replace(/\\/g, "/"); return nativePath.replace(/\\/g, "/");

View file

@ -18,7 +18,5 @@
"declarationMap": true, "declarationMap": true,
"sourceMap": true "sourceMap": true
}, },
"exclude": [ "exclude": ["dist"]
"dist"
]
} }

View file

@ -2,32 +2,32 @@ const path = require("path");
const webpack = require("webpack"); const webpack = require("webpack");
module.exports = { module.exports = {
entry: { entry: {
cli: "./src/cli.ts", cli: "./src/cli.ts",
healthcheck: "./src/healthcheck.ts" healthcheck: "./src/healthcheck.ts"
}, },
target: "node", target: "node",
mode: "production", mode: "production",
optimization: { optimization: {
minimize: false minimize: false
}, },
module: { module: {
rules: [ rules: [
{ {
test: /\.ts$/, test: /\.ts$/,
use: "ts-loader" use: "ts-loader"
} }
]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
] ]
},
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "[name].js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
}; };

View file

@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
**Note:** The Obsidian API is still in early alpha and is subject to change at any time! **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. 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 ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal. - Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page. - Adds a plugin setting tab to the settings page.
@ -57,31 +58,6 @@ Quick starting guide for new plugin devs:
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. - 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 ## API Documentation
See https://github.com/obsidianmd/obsidian-api See https://github.com/obsidianmd/obsidian-api

View file

@ -13,25 +13,25 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "^24.8.1", "@types/node": "^25.0.2",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.2",
"mini-css-extract-plugin": "^2.9.2", "mini-css-extract-plugin": "^2.9.4",
"obsidian": "1.10.2", "obsidian": "1.11.0",
"reconcile-text": "^0.8.0", "reconcile-text": "^0.8.0",
"resolve-url-loader": "^5.0.0", "resolve-url-loader": "^5.0.0",
"sass": "^1.91.0", "sass": "^1.96.0",
"sass-loader": "^16.0.6", "sass-loader": "^16.0.6",
"sync-client": "file:../sync-client", "sync-client": "file:../sync-client",
"terser-webpack-plugin": "^5.3.14", "terser-webpack-plugin": "^5.3.16",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.4",
"tslib": "2.8.1", "tslib": "2.8.1",
"tsx": "^4.20.6", "tsx": "^4.21.0",
"typescript": "5.8.3", "typescript": "5.9.3",
"url": "^0.11.4", "url": "^0.11.4",
"webpack": "^5.99.9", "webpack": "^5.103.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
} }

View file

@ -142,7 +142,7 @@ export default class VaultLinkPlugin extends Plugin {
}); });
if (IS_DEBUG_BUILD) { if (IS_DEBUG_BUILD) {
debugging.logToConsole(client); debugging.logToConsole(client.logger);
} }
return client; return client;

View file

@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab {
new Notice("Checking connection to the server..."); new Notice("Checking connection to the server...");
new Notice( new Notice(
( (await this.syncClient.checkConnection())
await this.syncClient.checkConnection() .serverMessage
).serverMessage
); );
await this.statusDescription.updateConnectionState(); await this.statusDescription.updateConnectionState();
} else { } else {

View file

@ -6,12 +6,7 @@
"strict": true, "strict": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"lib": [ "lib": ["DOM", "ES2024"]
"DOM",
"ES2024"
]
}, },
"exclude": [ "exclude": ["./dist"]
"./dist"
]
} }

View file

@ -46,7 +46,7 @@ module.exports = (env, argv) => ({
const source = path.resolve(__dirname, "dist"); const source = path.resolve(__dirname, "dist");
const destinations = [ const destinations = [
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link" // "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
]; ];
destinations.forEach((destination) => { destinations.forEach((destination) => {

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,19 @@
"trailingComma": "none", "trailingComma": "none",
"tabWidth": 4, "tabWidth": 4,
"useTabs": false, "useTabs": false,
"endOfLine": "lf" "endOfLine": "lf",
"overrides": [
{
"files": [
"*.yml",
"*.yaml",
"*.md"
],
"options": {
"tabWidth": 2
}
}
]
}, },
"scripts": { "scripts": {
"build": "npm run build --workspaces", "build": "npm run build --workspaces",
@ -22,11 +34,10 @@
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eclint": "^2.8.1", "eslint": "9.39.2",
"eslint": "9.38.0", "eslint-plugin-unused-imports": "^4.3.0",
"eslint-plugin-unused-imports": "^4.1.4", "npm-check-updates": "^19.2.0",
"npm-check-updates": "^19.1.1", "prettier": "^3.7.4",
"prettier": "^3.6.2", "typescript-eslint": "8.49.0"
"typescript-eslint": "8.41.0"
} }
} }

View file

@ -14,19 +14,17 @@
}, },
"devDependencies": { "devDependencies": {
"byte-base64": "^1.1.0", "byte-base64": "^1.1.0",
"minimatch": "^10.0.1", "minimatch": "^10.1.1",
"p-queue": "^8.1.0", "p-queue": "^9.0.1",
"reconcile-text": "^0.8.0", "reconcile-text": "^0.8.0",
"uuid": "^13.0.0", "@types/node": "^25.0.2",
"@types/node": "^24.8.1", "ts-loader": "^9.5.4",
"ts-loader": "^9.5.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"tsx": "^4.20.6", "tsx": "^4.21.0",
"typescript": "5.8.3", "typescript": "5.9.3",
"webpack": "^5.99.9", "webpack": "^5.103.0",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1", "webpack-merge": "^6.0.1",
"@sentry/browser": "^10.8.0", "@sentry/browser": "^10.30.0"
"ws": "^8.18.3"
} }
} }

View file

@ -2,5 +2,6 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const DIFF_CACHE_SIZE_MB = 2; export const DIFF_CACHE_SIZE_MB = 2;
export const MAX_LOG_MESSAGE_COUNT = 100000; export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000; export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 2; export const SUPPORTED_API_VERSION = 3;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10; export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;

View file

@ -45,11 +45,11 @@ export class FileOperations {
} }
/** /**
* Create a file at the specified 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. * If a file with the same name already exists, it is moved before creating the new one.
* Parent directories are created if necessary. * Parent directories are created if necessary.
*/ */
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array
@ -77,11 +77,11 @@ export class FileOperations {
} }
/** /**
* Update the file at the given path. * Update the file at the given path.
* *
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`. * 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. * Does not recreate the file if it no longer exists, returning an empty array instead.
*/ */
public async write( public async write(
path: RelativePath, path: RelativePath,
expectedContent: Uint8Array, expectedContent: Uint8Array,
@ -169,9 +169,9 @@ export class FileOperations {
} }
await this.ensureClearPath(newPath); await this.ensureClearPath(newPath);
this.database.move(oldPath, newPath); this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
} }
@ -239,12 +239,12 @@ export class FileOperations {
} }
/** /**
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed. * The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
* *
* @param path The starting path to deconflict * @param path The starting path to deconflict
* @returns a non-existent path with a lock acquired on it * @returns a non-existent path with a lock acquired on it
*/ */
private async deconflictPath(path: RelativePath): Promise<RelativePath> { private async deconflictPath(path: RelativePath): Promise<RelativePath> {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let [directory, fileName] = FileOperations.getParentDirAndFile(path); let [directory, fileName] = FileOperations.getParentDirAndFile(path);

View file

@ -2,7 +2,7 @@ import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations"; import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/data-structures/locks"; import { Locks } from "../utils/data-structures/locks";
import { FileNotFoundError } from "./file-not-found-error"; import { FileNotFoundError } from "../errors/file-not-found-error";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
/** /**
@ -135,10 +135,10 @@ export class SafeFileSystemOperations implements FileSystemOperations {
} }
/** /**
* Decorate an operation to ensure that the file exists before running it. * 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 * If the operation fails, it will check if the file still exists and throw
* a FileNotFoundError if it doesn't. * a FileNotFoundError if it doesn't.
*/ */
private async safeOperation<T>( private async safeOperation<T>(
path: RelativePath, path: RelativePath,
operation: () => Promise<T>, operation: () => Promise<T>,

View file

@ -27,8 +27,8 @@ export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan"; export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors"; export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status"; export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error"; export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
export type { AuthenticationError } from "./services/authentication-error"; export type { AuthenticationError } from "./errors/authentication-error";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status"; export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client"; export { SyncClient } from "./sync-client";

View file

@ -9,6 +9,7 @@ export type DocumentId = string;
export type RelativePath = string; export type RelativePath = string;
export interface DocumentMetadata { export interface DocumentMetadata {
documentId: DocumentId;
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
hash: string; hash: string;
remoteRelativePath?: RelativePath; remoteRelativePath?: RelativePath;
@ -25,7 +26,6 @@ export interface StoredDocumentMetadata {
export interface StoredDatabase { export interface StoredDatabase {
documents: StoredDocumentMetadata[]; documents: StoredDocumentMetadata[];
lastSeenUpdateId: VaultUpdateId | undefined; lastSeenUpdateId: VaultUpdateId | undefined;
hasInitialSyncCompleted: boolean;
} }
/** /**
@ -36,7 +36,6 @@ export interface StoredDatabase {
*/ */
export interface DocumentRecord { export interface DocumentRecord {
relativePath: RelativePath; relativePath: RelativePath;
documentId: DocumentId;
metadata: DocumentMetadata | undefined; metadata: DocumentMetadata | undefined;
isDeleted: boolean; isDeleted: boolean;
updates: Promise<unknown>[]; updates: Promise<unknown>[];
@ -46,7 +45,6 @@ export interface DocumentRecord {
export class Database { export class Database {
private documents: DocumentRecord[]; private documents: DocumentRecord[];
private lastSeenUpdateIds: CoveredValues; private lastSeenUpdateIds: CoveredValues;
private hasInitialSyncCompleted: boolean;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -56,16 +54,13 @@ export class Database {
initialState ??= {}; initialState ??= {};
this.documents = this.documents =
initialState.documents?.map( initialState.documents?.map(({ relativePath, ...metadata }) => ({
({ relativePath, documentId, ...metadata }) => ({ relativePath,
relativePath, metadata,
documentId, isDeleted: false,
metadata, updates: [],
isDeleted: false, parallelVersion: 0
updates: [], })) ?? [];
parallelVersion: 0
})
) ?? [];
this.ensureConsistency(); this.ensureConsistency();
this.logger.debug(`Loaded ${this.documents.length} documents`); this.logger.debug(`Loaded ${this.documents.length} documents`);
@ -79,12 +74,6 @@ export class Database {
this.documents.forEach((doc) => { this.documents.forEach((doc) => {
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId); this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
}); });
this.hasInitialSyncCompleted =
initialState.hasInitialSyncCompleted ?? false;
this.logger.debug(
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
);
} }
public get length(): number { public get length(): number {
@ -127,6 +116,7 @@ export class Database {
public updateDocumentMetadata( public updateDocumentMetadata(
metadata: { metadata: {
documentId: DocumentId;
parentVersionId: VaultUpdateId; parentVersionId: VaultUpdateId;
hash: string; hash: string;
remoteRelativePath: RelativePath; remoteRelativePath: RelativePath;
@ -180,7 +170,7 @@ export class Database {
if (entry === undefined) { if (entry === undefined) {
throw new Error( throw new Error(
`Document not found by relative path: ${relativePath}, ${JSON.stringify( `Document not found by relative path in getResolvedDocumentByRelativePath: ${relativePath}, ${JSON.stringify(
this.documents, this.documents,
null, null,
2 2
@ -196,19 +186,15 @@ export class Database {
} }
public createNewPendingDocument( public createNewPendingDocument(
documentId: DocumentId,
relativePath: RelativePath, relativePath: RelativePath,
promise: Promise<unknown> promise: Promise<unknown>
): DocumentRecord { ): DocumentRecord {
this.logger.debug( this.logger.debug(`Creating new pending document: ${relativePath}`);
`Creating new pending document: ${relativePath} (${documentId})`
);
const previousEntry = const previousEntry =
this.getLatestDocumentByRelativePath(relativePath); this.getLatestDocumentByRelativePath(relativePath);
const entry = { const entry = {
relativePath, relativePath,
documentId,
metadata: undefined, metadata: undefined,
isDeleted: false, isDeleted: false,
updates: [promise], updates: [promise],
@ -231,8 +217,8 @@ export class Database {
): DocumentRecord { ): DocumentRecord {
const entry = { const entry = {
relativePath, relativePath,
documentId,
metadata: { metadata: {
documentId,
parentVersionId, parentVersionId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: relativePath remoteRelativePath: relativePath
@ -251,7 +237,9 @@ export class Database {
public getDocumentByDocumentId( public getDocumentByDocumentId(
find: DocumentId find: DocumentId
): DocumentRecord | undefined { ): DocumentRecord | undefined {
return this.documents.find(({ documentId }) => documentId === find); return this.documents.find(
({ metadata }) => metadata?.documentId === find
);
} }
public move( public move(
@ -274,7 +262,7 @@ export class Database {
} }
oldDocument.relativePath = newRelativePath; oldDocument.relativePath = newRelativePath;
// We're in a strange state where the target of the move has just got deleted, // We might be 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 // 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. // the document at the new location. We need to keep these updates.
oldDocument.parallelVersion = oldDocument.parallelVersion =
@ -287,21 +275,16 @@ export class Database {
const candidate = this.getLatestDocumentByRelativePath(relativePath); const candidate = this.getLatestDocumentByRelativePath(relativePath);
if (candidate === undefined) { if (candidate === undefined) {
throw new Error( throw new Error(
`Document not found by relative path: ${relativePath}` `Document not found by relative path in delete: ${relativePath}, ${JSON.stringify(
this.documents,
null,
2
)}`
); );
} }
candidate.isDeleted = true; candidate.isDeleted = true;
} }
public getHasInitialSyncCompleted(): boolean {
return this.hasInitialSyncCompleted;
}
public setHasInitialSyncCompleted(value: boolean): void {
this.hasInitialSyncCompleted = value;
this.saveInTheBackground();
}
public getLastSeenUpdateId(): VaultUpdateId { public getLastSeenUpdateId(): VaultUpdateId {
return this.lastSeenUpdateIds.min; return this.lastSeenUpdateIds.min;
} }
@ -324,43 +307,50 @@ export class Database {
this.lastSeenUpdateIds = new CoveredValues( this.lastSeenUpdateIds = new CoveredValues(
0 // the first updateId will be 1 which is the first integer after -1 0 // the first updateId will be 1 which is the first integer after -1
); );
this.hasInitialSyncCompleted = false;
this.saveInTheBackground(); this.saveInTheBackground();
} }
public async save(): Promise<void> { public async save(): Promise<void> {
return this.saveData({ return this.saveData({
documents: this.resolvedDocuments.map( documents: this.resolvedDocuments.map(
({ relativePath, documentId, metadata }) => ({ ({ relativePath, metadata }) => ({
documentId,
relativePath, relativePath,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...metadata! // `resolvedDocuments` only returns docs with metadata set ...metadata! // `resolvedDocuments` only returns docs with metadata set
}) })
), ),
lastSeenUpdateId: this.lastSeenUpdateIds.min, lastSeenUpdateId: this.lastSeenUpdateIds.min
hasInitialSyncCompleted: this.hasInitialSyncCompleted
}); });
} }
private ensureConsistency(): void { private ensureConsistency(): void {
const idToPath = new Map<string, string[]>(); const idToPath = new Map<string, string[]>();
this.resolvedDocuments.forEach(({ relativePath, documentId }) => { this.resolvedDocuments.forEach(({ relativePath, metadata }) => {
idToPath.set(documentId, [ if (metadata === undefined) {
...(idToPath.get(documentId) ?? []), return;
}
idToPath.set(metadata.documentId, [
...(idToPath.get(metadata.documentId) ?? []),
relativePath relativePath
]); ]);
}); });
const duplicates = Array.from(idToPath.entries()) const duplicates = Array.from(idToPath.entries())
.filter(([_, paths]) => paths.length > 1) .filter(([_, paths]) => paths.length > 1)
.map(([id, paths]) => `${id} (${paths.join(", ")})`); .map(([id, paths]) => {
let details = "";
for (const path of paths) {
const doc = this.getLatestDocumentByRelativePath(path);
details += `\n- ${JSON.stringify(doc, null, 2)}`;
}
return `${id} (${paths.join(", ")}): ${details}`;
});
if (duplicates.length > 0) { if (duplicates.length > 0) {
throw new Error( throw new Error(
"Document IDs are not unique, found duplicates: " + "Document IDs are not unique, found duplicates: " +
duplicates.join("; ") duplicates.join("; ")
); );
} }
} }

View file

@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { FetchController } from "./fetch-controller"; import { FetchController } from "./fetch-controller";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import { SyncResetError } from "./sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
describe("FetchController", () => { describe("FetchController", () => {

View file

@ -1,6 +1,6 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "./sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
/** /**
* Offers a resettable fetch implementation that waits until syncing is enabled * Offers a resettable fetch implementation that waits until syncing is enabled
@ -25,18 +25,18 @@ export class FetchController {
} }
/** /**
* Whether the fetch implementation can immediately send requests once outside of a reset. * Whether the fetch implementation can immediately send requests once outside of a reset.
*/ */
public get canFetch(): boolean { public get canFetch(): boolean {
return this._canFetch; return this._canFetch;
} }
/** /**
* Allow or disallow fetching. The changes only take effect if not resetting. * Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished. * When called during a reset, its effect is deferred until the reset is finished.
* *
* @param canFetch Whether fetching is enabled * @param canFetch Whether fetching is enabled
*/ */
public set canFetch(canFetch: boolean) { public set canFetch(canFetch: boolean) {
this._canFetch = canFetch; this._canFetch = canFetch;
@ -59,9 +59,9 @@ export class FetchController {
} }
/** /**
* Starts a reset, causing all ongoing and future fetches to be rejected * Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called. * with a SyncResetError until finishReset is called.
*/ */
public startReset(): void { public startReset(): void {
this.isResetting = true; this.isResetting = true;
this.rejectUntil(new SyncResetError()); this.rejectUntil(new SyncResetError());
@ -72,9 +72,9 @@ export class FetchController {
} }
/** /**
* Finishes a reset, allowing fetches to proceed or wait again depending on * Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings. * the current sync settings.
*/ */
public finishReset(): void { public finishReset(): void {
if (!this.isResetting) { if (!this.isResetting) {
return; return;
@ -85,19 +85,19 @@ export class FetchController {
} }
/** /**
* *
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled | * | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request | * | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* *
* @param logger for errors * @param logger for errors
* @param fetch to wrap * @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state * @returns a wrapped fetch implementation affected by the FetchController state
*/ */
public getControlledFetchImplementation( public getControlledFetchImplementation(
logger: Logger, logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch fetch: typeof globalThis.fetch = globalThis.fetch

View file

@ -1,6 +1,6 @@
import { SUPPORTED_API_VERSION } from "../consts"; import { SUPPORTED_API_VERSION } from "../consts";
import { AuthenticationError } from "./authentication-error"; import { AuthenticationError } from "../errors/authentication-error";
import { ServerVersionMismatchError } from "./server-version-mismatch-error"; import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error";
import type { SyncService } from "./sync-service"; import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse"; import type { PingResponse } from "./types/PingResponse";
@ -34,11 +34,6 @@ export class ServerConfig {
} }
} }
// warm the cache
public async initialize(): Promise<void> {
await this.getConfig();
}
public async checkConnection(forceUpdate = false): Promise<{ public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean; isSuccessful: boolean;
message: string; message: string;

View file

@ -8,7 +8,7 @@ import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FetchController } from "./fetch-controller"; import type { FetchController } from "./fetch-controller";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import type { SerializedError } from "./types/SerializedError"; import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
@ -66,27 +66,29 @@ export class SyncService {
} }
public async create({ public async create({
documentId,
relativePath, relativePath,
contentBytes contentBytes,
forceMerge
}: { }: {
documentId?: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
contentBytes: Uint8Array; contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> { forceMerge?: boolean;
}): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
const formData = new FormData(); const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath); formData.append("relative_path", relativePath);
if (forceMerge === true) {
formData.append("force_merge", "true");
}
formData.append( formData.append(
"content", "content",
new Blob([new Uint8Array(contentBytes)]) new Blob([new Uint8Array(contentBytes)])
); );
this.logger.debug( this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}` `Creating document with relative path ${relativePath} (forceMerge: ${forceMerge})`
); );
const response = await this.client(this.getUrl("/documents"), { const response = await this.client(this.getUrl("/documents"), {
@ -103,8 +105,8 @@ export class SyncService {
); );
} }
const result: DocumentVersionWithoutContent = const result: DocumentUpdateResponse =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Created document ${JSON.stringify(result)}`); this.logger.debug(`Created document ${JSON.stringify(result)}`);
@ -155,8 +157,7 @@ export class SyncService {
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${result.documentId
result.documentId
}}` }}`
); );
@ -208,8 +209,7 @@ export class SyncService {
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug( this.logger.debug(
`Updated document ${JSON.stringify(result)} with id ${ `Updated document ${JSON.stringify(result)} with id ${result.documentId
result.documentId
}}` }}`
); );
@ -336,7 +336,7 @@ export class SyncService {
return this.retryForever(async () => { return this.retryForever(async () => {
this.logger.debug( this.logger.debug(
"Getting all documents" + "Getting all documents" +
(since != null ? ` since ${since}` : "") (since != null ? ` since ${since}` : "")
); );
const url = new URL(this.getUrl("/documents")); const url = new URL(this.getUrl("/documents"));

View file

@ -1,13 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export interface CreateDocumentVersion { 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; relative_path: string;
force_merge: boolean | null;
content: number[]; content: number[];
} }

View file

@ -7,7 +7,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
export interface FetchLatestDocumentsResponse { export interface FetchLatestDocumentsResponse {
latestDocuments: DocumentVersionWithoutContent[]; latestDocuments: DocumentVersionWithoutContent[];
/** /**
* The update ID of the latest document in the response. * The update ID of the latest document in the response.
*/ */
lastUpdateId: bigint; lastUpdateId: bigint;
} }

View file

@ -5,21 +5,21 @@
*/ */
export interface PingResponse { export interface PingResponse {
/** /**
* Semantic version of the server. * Semantic version of the server.
*/ */
serverVersion: string; serverVersion: string;
/** /**
* Whether the client is authenticated based on the sent Authorization * Whether the client is authenticated based on the sent Authorization
* header. * header.
*/ */
isAuthenticated: boolean; isAuthenticated: boolean;
/** /**
* List of file extensions that are allowed to be merged. * List of file extensions that are allowed to be merged.
*/ */
mergeableFileExtensions: string[]; mergeableFileExtensions: string[];
/** /**
* API version ensuring backwards & forwards compatibility between the client * API version ensuring backwards & forwards compatibility between the client
* and server. * and server.
*/ */
supportedApiVersion: number; supportedApiVersion: number;
} }

View file

@ -4,8 +4,6 @@ import assert from "node:assert";
import { WebSocketManager } from "./websocket-manager"; import { WebSocketManager } from "./websocket-manager";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const WebSocket = require("ws") as typeof globalThis.WebSocket;
class MockCloseEvent extends Event { class MockCloseEvent extends Event {
public code: number; public code: number;
@ -91,10 +89,8 @@ function createMockFn<T extends (...args: unknown[]) => unknown>(
describe("WebSocketManager", () => { describe("WebSocketManager", () => {
let mockLogger: Logger = undefined as unknown as Logger; let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings; let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123";
beforeEach(() => { beforeEach(() => {
deviceId = "test-device-123";
const noop = (): void => { const noop = (): void => {
// Intentionally empty for mock // Intentionally empty for mock
}; };
@ -116,7 +112,6 @@ describe("WebSocketManager", () => {
it("cleans up promises after message handling", async () => { it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
@ -146,7 +141,6 @@ describe("WebSocketManager", () => {
it("cleans up cursor position promises", async () => { it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
@ -176,7 +170,6 @@ describe("WebSocketManager", () => {
it("logs handshake send errors", async () => { it("logs handshake send errors", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
@ -205,7 +198,6 @@ describe("WebSocketManager", () => {
it("completes stop with timeout protection", async () => { it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
@ -220,7 +212,6 @@ describe("WebSocketManager", () => {
it("clears old handlers on reconnection", async () => { it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket
@ -257,7 +248,6 @@ describe("WebSocketManager", () => {
it("tracks message handling promises", async () => { it("tracks message handling promises", async () => {
const manager = new WebSocketManager( const manager = new WebSocketManager(
deviceId,
mockLogger, mockLogger,
mockSettings, mockSettings,
MockWebSocket as unknown as typeof WebSocket MockWebSocket as unknown as typeof WebSocket

View file

@ -6,7 +6,10 @@ import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"
import type { ClientCursors } from "./types/ClientCursors"; import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts"; import {
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
} from "../consts";
import { removeFromArray } from "../utils/remove-from-array"; import { removeFromArray } from "../utils/remove-from-array";
import { EventListeners } from "../utils/data-structures/event-listeners"; import { EventListeners } from "../utils/data-structures/event-listeners";
import { awaitAll } from "../utils/await-all"; import { awaitAll } from "../utils/await-all";
@ -27,32 +30,17 @@ export class WebSocketManager {
private isStopped = true; private isStopped = true;
private resolveDisconnectingPromise: null | (() => unknown) = null; private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined; private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
private readonly outstandingPromises: Promise<unknown>[] = []; private readonly outstandingPromises: Promise<unknown>[] = [];
private webSocket: WebSocket | undefined; private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor( public constructor(
private readonly deviceId: string,
private readonly logger: Logger, private readonly logger: Logger,
private readonly settings: Settings, private readonly settings: Settings,
webSocketImplementation?: typeof globalThis.WebSocket private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket
) { ) {}
if (webSocketImplementation) {
this.webSocketFactoryImplementation = webSocketImplementation;
} else {
if (
typeof globalThis !== "undefined" &&
typeof globalThis.WebSocket === "undefined"
) {
// eslint-disable-next-line
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
} else {
this.webSocketFactoryImplementation = WebSocket;
}
}
}
public get isWebSocketConnected(): boolean { public get isWebSocketConnected(): boolean {
return ( return (
@ -77,6 +65,11 @@ export class WebSocketManager {
this.reconnectTimeoutId = undefined; this.reconnectTimeoutId = undefined;
} }
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
this.webSocket?.close(1000, "WebSocketManager has been stopped"); this.webSocket?.close(1000, "WebSocketManager has been stopped");
// eslint-disable-next-line @typescript-eslint/init-declarations // eslint-disable-next-line @typescript-eslint/init-declarations
@ -85,10 +78,10 @@ export class WebSocketManager {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
reject( reject(
new Error( new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds` `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds`
) )
); );
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000); }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000);
}); });
try { try {
@ -171,7 +164,10 @@ export class WebSocketManager {
this.webSocket.onclose = null; this.webSocket.onclose = null;
this.webSocket.onmessage = null; this.webSocket.onmessage = null;
this.webSocket.onerror = null; this.webSocket.onerror = null;
this.webSocket.close(); this.webSocket.close(
1000,
"Closing previous WebSocket connection"
);
} catch (e) { } catch (e) {
this.logger.error( this.logger.error(
`Failed to close previous WebSocket connection: ${e}` `Failed to close previous WebSocket connection: ${e}`
@ -187,7 +183,22 @@ export class WebSocketManager {
this.webSocket = new this.webSocketFactoryImplementation(wsUri); this.webSocket = new this.webSocketFactoryImplementation(wsUri);
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open
this.connectionTimeoutId = setTimeout(() => {
this.connectionTimeoutId = undefined;
this.logger.warn(
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
);
// Force close to trigger onclose handler which will schedule reconnection
this.webSocket?.close(1000, "Connection timeout");
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
this.webSocket.onopen = (): void => { this.webSocket.onopen = (): void => {
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
// Check if we've been stopped while connecting // Check if we've been stopped while connecting
if (this.isStopped) { if (this.isStopped) {
this.webSocket?.close( this.webSocket?.close(
@ -231,7 +242,18 @@ export class WebSocketManager {
} }
}; };
this.webSocket.onerror = (error): void => {
this.logger.warn(
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
);
};
this.webSocket.onclose = (event): void => { this.webSocket.onclose = (event): void => {
if (this.connectionTimeoutId !== undefined) {
clearTimeout(this.connectionTimeoutId);
this.connectionTimeoutId = undefined;
}
this.logger.warn( this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
); );
@ -241,10 +263,13 @@ export class WebSocketManager {
this.resolveDisconnectingPromise?.(); this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null; this.resolveDisconnectingPromise = null;
} else { } else {
const delay =
this.settings.getSettings().webSocketRetryIntervalMs;
this.logger.info(`Reconnecting to WebSocket in ${delay}ms...`);
this.reconnectTimeoutId = setTimeout(() => { this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = undefined; this.reconnectTimeoutId = undefined;
this.initializeWebSocket(); this.initializeWebSocket();
}, this.settings.getSettings().webSocketRetryIntervalMs); }, delay);
} }
}; };
} }

View file

@ -29,7 +29,6 @@ import { ServerConfig } from "./services/server-config";
import type { EventListeners } from "./utils/data-structures/event-listeners"; import type { EventListeners } from "./utils/data-structures/event-listeners";
export class SyncClient { export class SyncClient {
private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false; private hasFinishedOfflineSync = false;
private hasStarted = false; private hasStarted = false;
private hasBeenDestroyed = false; private hasBeenDestroyed = false;
@ -41,6 +40,7 @@ export class SyncClient {
private readonly history: SyncHistory, private readonly history: SyncHistory,
private readonly settings: Settings, private readonly settings: Settings,
private readonly database: Database, private readonly database: Database,
private readonly unrestrictedSyncer: UnrestrictedSyncer,
private readonly syncer: Syncer, private readonly syncer: Syncer,
private readonly webSocketManager: WebSocketManager, private readonly webSocketManager: WebSocketManager,
public readonly logger: Logger, public readonly logger: Logger,
@ -56,7 +56,7 @@ export class SyncClient {
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> }>
> >
) {} ) { }
public get documentCount(): number { public get documentCount(): number {
return this.database.length; return this.database.length;
@ -195,7 +195,6 @@ export class SyncClient {
); );
const webSocketManager = new WebSocketManager( const webSocketManager = new WebSocketManager(
deviceId,
logger, logger,
settings, settings,
webSocket webSocket
@ -206,7 +205,6 @@ export class SyncClient {
logger, logger,
database, database,
settings, settings,
syncService,
webSocketManager, webSocketManager,
fileOperations, fileOperations,
unrestrictedSyncer unrestrictedSyncer
@ -223,6 +221,7 @@ export class SyncClient {
history, history,
settings, settings,
database, database,
unrestrictedSyncer,
syncer, syncer,
webSocketManager, webSocketManager,
logger, logger,
@ -285,10 +284,10 @@ export class SyncClient {
} }
/** /**
* Reload settings from disk overriding current in-memory settings. * Reload settings from disk overriding current in-memory settings.
* Missing values will be filled in from DEFAULT_SETTINGS rather than * Missing values will be filled in from DEFAULT_SETTINGS rather than
* retaining current in-memory settings. * retaining current in-memory settings.
*/ */
public async reloadSettings(): Promise<void> { public async reloadSettings(): Promise<void> {
this.checkIfDestroyed("reloadSettings"); this.checkIfDestroyed("reloadSettings");
@ -320,10 +319,10 @@ export class SyncClient {
} }
/** /**
* Wait for the in-flight operations to finish, reset all tracking, * Wait for the in-flight operations to finish, reset all tracking,
* and the local database but retain the settings. * and the local database but retain the settings.
* The SyncClient can be used again after calling this method. * The SyncClient can be used again after calling this method.
*/ */
public async reset(): Promise<void> { public async reset(): Promise<void> {
this.checkIfDestroyed("reset"); this.checkIfDestroyed("reset");
@ -337,11 +336,12 @@ export class SyncClient {
this.database.reset(); this.database.reset();
await this.database.save(); // ensure the new database reads as empty await this.database.save(); // ensure the new database reads as empty
this.resetInMemoryState(); this.resetInMemoryState();
this.hasStartedOfflineSync = false;
this.hasFinishedOfflineSync = false; this.hasFinishedOfflineSync = false;
this.serverConfig.reset(); this.serverConfig.reset();
await this.startSyncing(); if (this.settings.getSettings().isSyncEnabled) {
await this.startSyncing();
}
} }
public getSettings(): SyncSettings { public getSettings(): SyncSettings {
@ -369,7 +369,9 @@ export class SyncClient {
this.checkIfDestroyed("syncLocallyCreatedFile"); this.checkIfDestroyed("syncLocallyCreatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath); this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath); return this.syncer.syncLocallyCreatedFile(relativePath, {
forceMerge: false
});
} }
public async syncLocallyDeletedFile( public async syncLocallyDeletedFile(
@ -436,9 +438,9 @@ export class SyncClient {
} }
/** /**
* Completely destroy the SyncClient, cancelling all in-progress operations. * Completely destroy the SyncClient, cancelling all in-progress operations.
* After calling this method, the SyncClient cannot be used again. * After calling this method, the SyncClient cannot be used again.
*/ */
public async destroy(): Promise<void> { public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy"); this.checkIfDestroyed("destroy");
@ -473,18 +475,17 @@ export class SyncClient {
this.checkIfDestroyed("startSyncing"); this.checkIfDestroyed("startSyncing");
this.fetchController.finishReset(); this.fetchController.finishReset();
await this.serverConfig.initialize(); // warm the cache
this.webSocketManager.start(); await this.serverConfig.getConfig();
if (!this.hasStartedOfflineSync) { await this.syncer.scheduleSyncForOfflineChanges();
this.hasStartedOfflineSync = true; this.webSocketManager.start();
await this.syncer.scheduleSyncForOfflineChanges();
}
this.hasFinishedOfflineSync = true; this.hasFinishedOfflineSync = true;
} }
private async pause(): Promise<void> { private async pause(): Promise<void> {
this.hasFinishedOfflineSync = false;
this.fetchController.startReset(); this.fetchController.startReset();
await this.webSocketManager.stop(); await this.webSocketManager.stop();
await this.waitUntilFinished(); await this.waitUntilFinished();
@ -496,6 +497,7 @@ export class SyncClient {
// don't reset the logger // don't reset the logger
this.cursorTracker.reset(); this.cursorTracker.reset();
this.syncer.reset(); this.syncer.reset();
this.unrestrictedSyncer.reset();
this.fileOperations.reset(); this.fileOperations.reset();
} }

View file

@ -113,7 +113,7 @@ export class CursorTracker {
documentsWithCursors.push({ documentsWithCursors.push({
relative_path: relativePath, relative_path: relativePath,
document_id: record.documentId, document_id: record.metadata.documentId,
vault_update_id: record.metadata.parentVersionId, vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({ cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end), start: Math.min(start, end),

View file

@ -4,17 +4,15 @@ import type {
DocumentRecord, DocumentRecord,
RelativePath RelativePath
} from "../persistence/database"; } from "../persistence/database";
import type { SyncService } from "../services/sync-service";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import PQueue from "p-queue"; import PQueue from "p-queue";
import { hash } from "../utils/hash"; import { hash } from "../utils/hash";
import { v4 as uuidv4 } from "uuid";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { findMatchingFile } from "../utils/find-matching-file"; import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer"; import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import { Locks } from "../utils/data-structures/locks"; import { Locks } from "../utils/data-structures/locks";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
@ -42,10 +40,9 @@ export class Syncer {
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly database: Database,
private readonly settings: Settings, private readonly settings: Settings,
private readonly syncService: SyncService,
private readonly webSocketManager: WebSocketManager, private readonly webSocketManager: WebSocketManager,
private readonly operations: FileOperations, private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer private readonly unrestrictedSyncer: UnrestrictedSyncer
) { ) {
this.syncQueue = new PQueue({ this.syncQueue = new PQueue({
concurrency: settings.getSettings().syncConcurrency concurrency: settings.getSettings().syncConcurrency
@ -84,12 +81,15 @@ export class Syncer {
} }
public async syncLocallyCreatedFile( public async syncLocallyCreatedFile(
relativePath: RelativePath relativePath: RelativePath,
{ forceMerge }: { forceMerge: boolean }
): Promise<void> { ): Promise<void> {
if ( if (
this.database.getLatestDocumentByRelativePath(relativePath) this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === false ?.isDeleted === false
) { ) {
// This is likely a consequence of us creating a file because of a remote update
// which triggered a local create, so we don't need to do anything here.
this.logger.debug( this.logger.debug(
`Document ${relativePath} already exists in the database, skipping` `Document ${relativePath} already exists in the database, skipping`
); );
@ -97,18 +97,22 @@ export class Syncer {
} }
const [promise, resolve, reject] = createPromise(); const [promise, resolve, reject] = createPromise();
this.logger.warn(`creating ${relativePath} locally`);
const id = uuidv4();
const document = this.database.createNewPendingDocument( const document = this.database.createNewPendingDocument(
id,
relativePath, relativePath,
promise promise
); );
try { try {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document) this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile(
); { document, forceMerge }
)
)
this.logger.warn(`done creating ${relativePath} locally`);
resolve(); resolve();
} catch (e) { } catch (e) {
@ -128,7 +132,7 @@ export class Syncer {
// This is must be a consequence of us deleting a file because of a remote update // 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. // which triggered a local delete, so we don't need to do anything here.
this.logger.debug( this.logger.debug(
`Document ${relativePath} has already been markes as deleted, skipping` `Document ${relativePath} has already been marked as deleted, skipping`
); );
return; return;
} }
@ -146,7 +150,7 @@ export class Syncer {
try { try {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document) this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile(document)
); );
resolve(); resolve();
@ -171,7 +175,7 @@ export class Syncer {
// in that case, we mustn't move it again. // in that case, we mustn't move it again.
if ( if (
this.database.getLatestDocumentByRelativePath(relativePath) === this.database.getLatestDocumentByRelativePath(relativePath) ===
undefined || undefined ||
this.database.getLatestDocumentByRelativePath(relativePath) this.database.getLatestDocumentByRelativePath(relativePath)
?.isDeleted === true ?.isDeleted === true
) { ) {
@ -188,6 +192,8 @@ export class Syncer {
let document = let document =
this.database.getLatestDocumentByRelativePath(relativePath); this.database.getLatestDocumentByRelativePath(relativePath);
this.logger.warn(`sync doc ${JSON.stringify(document)} for path ${relativePath} (old path: ${oldPath}), len docs: ${document?.updates.length}`);
if ( if (
oldPath !== undefined && oldPath !== undefined &&
document?.metadata?.remoteRelativePath === relativePath document?.metadata?.remoteRelativePath === relativePath
@ -198,6 +204,7 @@ export class Syncer {
return; return;
} }
// must have been removed after a successful delete
if (document === undefined) { if (document === undefined) {
this.logger.debug( this.logger.debug(
`Cannot find document ${relativePath} in the database, skipping` `Cannot find document ${relativePath} in the database, skipping`
@ -218,12 +225,13 @@ export class Syncer {
relativePath, relativePath,
promise promise
); );
this.logger.warn(`updating ${document.relativePath} locally`);
try { try {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({ this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile({
oldPath, oldPath,
document document: document!
}) })
); );
@ -257,8 +265,6 @@ export class Syncer {
`Not all local changes have been applied remotely: ${e}` `Not all local changes have been applied remotely: ${e}`
); );
throw e; throw e;
} finally {
this.runningScheduleSyncForOfflineChanges = undefined;
} }
} }
@ -271,6 +277,8 @@ export class Syncer {
message: WebSocketVaultUpdate message: WebSocketVaultUpdate
): Promise<void> { ): Promise<void> {
try { try {
await this.scheduleSyncForOfflineChanges();
const handlerPromise = awaitAll( const handlerPromise = awaitAll(
message.documents.map(async (document) => message.documents.map(async (document) =>
this.internalSyncRemotelyUpdatedFile(document) this.internalSyncRemotelyUpdatedFile(document)
@ -317,25 +325,45 @@ export class Syncer {
remoteVersion.documentId remoteVersion.documentId
); );
this.logger.warn(`${remoteVersion.documentId} got remote update ${JSON.stringify(remoteVersion)}`);
if (document === undefined) { if (document === undefined) {
// Let's avoid the same documents getting created in parallel multiple times. this.logger.warn(`${remoteVersion.documentId} but document doesn't exist`)
// There might be multiple tasks waiting for the lock
return this.remoteDocumentsLock.withLock( return this.remoteDocumentsLock.withLock(
// Avoid the same documents getting created in parallel multiple times through fetching multiple updates of the same
// new remote document concurrently.
// There might be multiple tasks waiting for the lock
remoteVersion.documentId, remoteVersion.documentId,
async () => { async () => {
// We have to wait for any ongoing creates sent for this file to finish,
// This is to avoid fetching one's own creates before the corresponding local create has finished syncing. This is a concern because
// documents being created don't yet have a document id in the local database and we could be notified of the remote create
// before the local create has finished syncing, so we can't just ignore the update based on the local DB content as we
// can't find the corresponding document yet.
if (document?.metadata === undefined) {
await this.unrestrictedSyncer.fileCreationLock.waitForLockWithoutAcquiringLock(remoteVersion.relativePath);
}
document = this.database.getDocumentByDocumentId( document = this.database.getDocumentByDocumentId(
remoteVersion.documentId remoteVersion.documentId
); );
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` this.logger.warn(`${remoteVersion.documentId} rechecking, document is now ${JSON.stringify(document)}`)
// We're the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
if (document === undefined) { if (document === undefined) {
this.logger.warn(`${remoteVersion.documentId} document is undefined, creating new document`)
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion remoteVersion
) )
); );
} else { } else {
const [promise, resolve, reject] = createPromise(); const [promise, resolve, reject] =
createPromise();
document = document =
await this.database.getResolvedDocumentByRelativePath( await this.database.getResolvedDocumentByRelativePath(
@ -345,7 +373,7 @@ export class Syncer {
try { try {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion, remoteVersion,
document document
) )
@ -355,13 +383,19 @@ export class Syncer {
} catch (e) { } catch (e) {
reject(e); reject(e);
} finally { } finally {
this.database.removeDocumentPromise(promise); this.database.removeDocumentPromise(
promise
);
} }
} }
this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); this.database.addSeenUpdateId(
remoteVersion.vaultUpdateId
);
} }
); )
} else {
this.logger.warn(`${remoteVersion.documentId} and document exists (path: ${JSON.stringify(document)})`);
} }
// We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile`
@ -374,7 +408,7 @@ export class Syncer {
try { try {
await this.syncQueue.add(async () => await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile(
remoteVersion, remoteVersion,
document document
) )
@ -391,8 +425,6 @@ export class Syncer {
} }
private async internalScheduleSyncForOfflineChanges(): Promise<void> { private async internalScheduleSyncForOfflineChanges(): Promise<void> {
await this.createFakeDocumentsFromRemoteState();
const allLocalFiles = await this.operations.listFilesRecursively(); const allLocalFiles = await this.operations.listFilesRecursively();
this.logger.info( this.logger.info(
`Scheduling sync for ${allLocalFiles.length} local files` `Scheduling sync for ${allLocalFiles.length} local files`
@ -409,7 +441,8 @@ export class Syncer {
} }
} }
await awaitAll( type Instruction = { "type": "update" | "create", relativePath: string, oldPath?: string };
const instructions: (Instruction | undefined)[] = await awaitAll(
allLocalFiles.map(async (relativePath) => { allLocalFiles.map(async (relativePath) => {
if ( if (
this.database.getLatestDocumentByRelativePath(relativePath) this.database.getLatestDocumentByRelativePath(relativePath)
@ -419,16 +452,24 @@ export class Syncer {
`Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`
); );
return this.syncLocallyUpdatedFile({ return { type: "update", relativePath } as Instruction;
relativePath
});
} }
// Perhaps the file has been moved; let's check by looking at the deleted files // Perhaps the file has been moved; let's check by looking at the deleted files
const contentHash = await this.syncQueue.add(async () => { const contentHash = await this.syncQueue.add(async () => {
const contentBytes = try {
await this.operations.read(relativePath); // this can throw FileNotFoundError const contentBytes =
return hash(contentBytes); await this.operations.read(relativePath); // this can throw FileNotFoundError
return hash(contentBytes);
} catch (e) {
if (
e instanceof Error &&
e.name === "FileNotFoundError"
) {
return undefined;
}
throw e;
}
}); });
if (contentHash == undefined) { if (contentHash == undefined) {
@ -454,21 +495,26 @@ export class Syncer {
`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` `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 {
return this.syncLocallyUpdatedFile({ type: "update",
oldPath: originalFile.relativePath, oldPath: originalFile.relativePath,
relativePath relativePath
}); } as Instruction;
} }
this.logger.debug( this.logger.debug(
`Document ${relativePath} not found in database, scheduling sync to create it` `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); return {
type: "create",
relativePath
} as Instruction;
}) })
); );
// this has to happen strictly after the previous awaitAll, as that one // this has to happen strictly after the previous awaitAll, as that one
// might have removed some of the documents from the list // might have removed some of the documents from the list
await awaitAll( await awaitAll(
@ -481,42 +527,36 @@ export class Syncer {
return this.syncLocallyDeletedFile(relativePath); return this.syncLocallyDeletedFile(relativePath);
}) })
); );
}
/**
* Create fake documents in the database for all files that are present locally
* and also exist remotely. This will stop the subequent syncs from duplicating
* the documents by creating the same documents from multiple clients.
*/
private async createFakeDocumentsFromRemoteState(): Promise<void> {
if (this.database.getHasInitialSyncCompleted()) {
return;
}
const [allLocalFiles, remote] = await awaitAll([ await awaitAll(instructions.map(async (instruction) => {
this.operations.listFilesRecursively(), if (instruction === undefined) {
this.syncQueue.add(async () => this.syncService.getAll()) return;
]); }
if (remote !== undefined) { if (instruction.type === "update") {
remote.latestDocuments // We're outside of the pqueue, so we need to call the public wrapper
.filter( return await this.syncLocallyUpdatedFile({
(remoteDocument) => oldPath: instruction.oldPath,
allLocalFiles.includes(remoteDocument.relativePath) && relativePath: instruction.relativePath
!remoteDocument.isDeleted &&
this.database.getDocumentByDocumentId(
remoteDocument.documentId
) === undefined
)
.forEach((remoteDocument) => {
this.database.createNewEmptyDocument(
remoteDocument.documentId,
remoteDocument.vaultUpdateId,
remoteDocument.relativePath
);
}); });
} }
}));
// we have to ensure the deletes & updates have finished before starting creates,
// otherwise the server might return an existing document (that we're about to delete)
// instead of actually creating a new one
await awaitAll(instructions.map(async (instruction) => {
if (instruction === undefined) {
return;
}
if (instruction.type === "create") {
// We're outside of the pqueue, so we need to call the public wrapper
return await this.syncLocallyCreatedFile(instruction.relativePath, { forceMerge: true });
}
}));
this.database.setHasInitialSyncCompleted(true);
} }
} }

View file

@ -23,8 +23,8 @@ import { base64ToBytes } from "byte-base64";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations"; import type { FileOperations } from "../file-operations/file-operations";
import { createPromise } from "../utils/create-promise"; import { createPromise } from "../utils/create-promise";
import { FileNotFoundError } from "../file-operations/file-not-found-error"; import { FileNotFoundError } from "../errors/file-not-found-error";
import { SyncResetError } from "../services/sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import { globsToRegexes } from "../utils/globs-to-regexes"; import { globsToRegexes } from "../utils/globs-to-regexes";
import type { DocumentVersion } from "../services/types/DocumentVersion"; import type { DocumentVersion } from "../services/types/DocumentVersion";
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
@ -33,9 +33,12 @@ import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-
import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isFileTypeMergable } from "../utils/is-file-type-mergable";
import { isBinary } from "../utils/is-binary"; import { isBinary } from "../utils/is-binary";
import type { ServerConfig } from "../services/server-config"; import type { ServerConfig } from "../services/server-config";
import { Locks } from "../utils/data-structures/locks";
export class UnrestrictedSyncer { export class UnrestrictedSyncer {
private ignorePatterns: RegExp[]; private ignorePatterns: RegExp[];
public readonly fileCreationLock: Locks<RelativePath> = new Locks<RelativePath>();
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -60,68 +63,202 @@ export class UnrestrictedSyncer {
}); });
} }
public async unrestrictedSyncLocallyCreatedFile( public async unrestrictedSyncLocallyCreatedOrUpdatedFile({
document: DocumentRecord oldPath,
): Promise<void> { document,
const updateDetails: SyncCreateDetails = { forceMerge,
type: SyncType.CREATE, // We use the same code path for both local and remote updates. We need to force the update
relativePath: document.relativePath // if there are no local changes but we know that the remote version is newer.
}; force = false
}: {
oldPath?: RelativePath;
force?: boolean;
forceMerge?: boolean
document: DocumentRecord;
}): Promise<void> {
return this.executeSync(updateDetails, async () => { // this.history.addHistoryEntry({
// status: SyncStatus.SUCCESS,
// details: updateDetails,
// message: `Successfully uploaded locally created file`
// });
let updateDetails: SyncCreateDetails | SyncUpdateDetails | SyncMovedDetails;
if (document.metadata === undefined) {
updateDetails = {
type: SyncType.CREATE,
relativePath: document.relativePath
};
}
else if (oldPath !== undefined) {
updateDetails = {
type: SyncType.MOVE,
relativePath: document.relativePath,
movedFrom: oldPath
};
} else {
updateDetails = {
type: SyncType.UPDATE,
relativePath: document.relativePath
};
}
await this.executeSync(updateDetails, async () => {
const originalRelativePath = document.relativePath; const originalRelativePath = document.relativePath;
if (document.isDeleted) { if (document.isDeleted) {
this.logger.debug( this.logger.debug(
`Document ${originalRelativePath} has been already deleted, no need to create it` `Document ${document.relativePath} has been already deleted, no need to update it`
); );
return; return;
} }
const contentBytes = const contentBytes = await this.operations.read(
await this.operations.read(originalRelativePath); // this can throw FileNotFoundError document.relativePath
); // this can throw FileNotFoundError
const contentHash = hash(contentBytes); const contentHash = hash(contentBytes);
const response = await this.syncService.create({ this.logger.warn(`updating ${document.relativePath} locally, inner`);
documentId: document.documentId,
relativePath: originalRelativePath,
contentBytes
});
// In case a document with the same name (but different ID) had existed remotely that we haven't known about let response: DocumentVersion | DocumentUpdateResponse | undefined =
if (response.relativePath != originalRelativePath) { undefined;
this.logger.debug(
`Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally` if (document.metadata === undefined) {
); response = await this.fileCreationLock.withLock(document.relativePath, async () => {
await this.operations.move( const response = await this.syncService.create({
document.relativePath, relativePath: originalRelativePath,
response.relativePath contentBytes,
); // this can throw FileNotFoundError forceMerge
});
await this.handleMaybeMergingResponse({
document,
response,
contentHash,
originalRelativePath,
originalContentBytes: contentBytes
});
return response;
});
} else {
const areThereLocalChanges =
document.metadata.hash !== contentHash || oldPath !== undefined;
if (areThereLocalChanges) {
const isText =
!isBinary(contentBytes) &&
isFileTypeMergable(
document.relativePath,
(await this.serverConfig.getConfig())
.mergeableFileExtensions
);
const cachedVersion = this.contentCache.get(
document.metadata.parentVersionId
);
response =
isText && cachedVersion !== undefined
? await this.syncService.putText({
documentId: document.metadata.documentId,
parentVersionId:
document.metadata.parentVersionId,
relativePath: document.relativePath,
content: diff(
new TextDecoder().decode(cachedVersion),
new TextDecoder().decode(contentBytes)
)
})
: await this.syncService.putBinary({
documentId: document.metadata.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;
}
// we use this code path (force == true) to sync remotely updated files which have no local changes
response = await this.syncService.get({
documentId: document.metadata.documentId
});
}
await this.handleMaybeMergingResponse({
document,
response,
contentHash,
originalRelativePath,
originalContentBytes: contentBytes
});
} }
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
this.database.addSeenUpdateId(response.vaultUpdateId);
await this.updateCache(
response.vaultUpdateId,
contentBytes,
response.relativePath
);
this.history.addHistoryEntry({ if (!("type" in response) || response.type === "MergingUpdate") {
status: SyncStatus.SUCCESS, if (!force) {
details: updateDetails, this.history.addHistoryEntry({
message: `Successfully uploaded locally created file` status: SyncStatus.SUCCESS,
}); details: updateDetails,
message: `The file we updated had been updated remotely, so we downloaded the merged version`
});
return;
}
}
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
if (!response.isDeleted) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: actualUpdateDetails,
message: `Successfully downloaded remotely updated file from the server`,
author: response.userId,
timestamp: new Date(response.updatedDate)
});
} else {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message:
"File has been deleted remotely, so we deleted it locally",
author: response.userId,
timestamp: new Date(response.updatedDate)
});
}
}); });
} }
public async unrestrictedSyncLocallyDeletedFile( public async unrestrictedSyncLocallyDeletedFile(
document: DocumentRecord document: DocumentRecord
): Promise<void> { ): Promise<void> {
@ -131,13 +268,21 @@ export class UnrestrictedSyncer {
}; };
await this.executeSync(updateDetails, async () => { await this.executeSync(updateDetails, async () => {
if (document.metadata === undefined) {
this.logger.debug(
`Document ${document.relativePath} has never been synced, no need to delete it remotely`
);
return;
}
const response = await this.syncService.delete({ const response = await this.syncService.delete({
documentId: document.documentId, documentId: document.metadata.documentId,
relativePath: document.relativePath relativePath: document.relativePath
}); });
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: response.documentId,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: document.relativePath remoteRelativePath: document.relativePath
@ -156,214 +301,6 @@ export class UnrestrictedSyncer {
}); });
} }
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<void> {
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) {
const isText =
!isBinary(contentBytes) &&
isFileTypeMergable(
document.relativePath,
(await this.serverConfig.getConfig())
.mergeableFileExtensions
);
const cachedVersion = this.contentCache.get(
document.metadata.parentVersionId
);
response =
isText && cachedVersion !== undefined
? await this.syncService.putText({
documentId: document.documentId,
parentVersionId:
document.metadata.parentVersionId,
relativePath: document.relativePath,
content: diff(
new TextDecoder().decode(cachedVersion),
new TextDecoder().decode(contentBytes)
)
})
: await this.syncService.putBinary({
documentId: document.documentId,
parentVersionId:
document.metadata.parentVersionId,
relativePath: document.relativePath,
contentBytes
});
} else {
if (!force) {
this.logger.debug(
`File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync`
);
return;
}
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) {
return this.applyRemoteDeleteLocally(document, response);
}
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 = base64ToBytes(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.write(
actualPath,
contentBytes,
responseBytes
);
await this.updateCache(
response.vaultUpdateId,
responseBytes,
actualPath
);
if (!force) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: updateDetails,
message: `The file we updated had been updated remotely, so we downloaded the merged version`
});
}
} else {
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.updateCache(
response.vaultUpdateId,
contentBytes,
actualPath
);
}
this.database.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,
timestamp: new Date(response.updatedDate)
});
}
});
}
public async unrestrictedSyncRemotelyUpdatedFile( public async unrestrictedSyncRemotelyUpdatedFile(
remoteVersion: DocumentVersionWithoutContent, remoteVersion: DocumentVersionWithoutContent,
document?: DocumentRecord document?: DocumentRecord
@ -373,6 +310,7 @@ export class UnrestrictedSyncer {
relativePath: remoteVersion.relativePath relativePath: remoteVersion.relativePath
}; };
await this.executeSync(updateDetails, async () => { await this.executeSync(updateDetails, async () => {
if (document?.metadata !== undefined) { if (document?.metadata !== undefined) {
// If the file exists locally, let's pretend the user has updated it // If the file exists locally, let's pretend the user has updated it
@ -388,7 +326,7 @@ export class UnrestrictedSyncer {
return; return;
} }
return this.unrestrictedSyncLocallyUpdatedFile({ return this.unrestrictedSyncLocallyCreatedOrUpdatedFile({
document, document,
force: true force: true
}); });
@ -437,12 +375,12 @@ export class UnrestrictedSyncer {
const [promise, resolve] = createPromise(); const [promise, resolve] = createPromise();
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: remoteVersion.documentId,
parentVersionId: remoteVersion.vaultUpdateId, parentVersionId: remoteVersion.vaultUpdateId,
hash: hash(contentBytes), hash: hash(contentBytes),
remoteRelativePath: remoteVersion.relativePath remoteRelativePath: remoteVersion.relativePath
}, },
this.database.createNewPendingDocument( this.database.createNewPendingDocument(
remoteVersion.documentId,
remoteVersion.relativePath, remoteVersion.relativePath,
promise promise
) )
@ -471,10 +409,21 @@ export class UnrestrictedSyncer {
}); });
} }
public async executeSync<T>( public reset(): void {
this.fileCreationLock.reset();
}
private async executeSync<T>(
details: SyncDetails, details: SyncDetails,
fn: () => Promise<T> fn: () => Promise<T>
): Promise<T | undefined> { ): Promise<T | undefined> {
if (!this.settings.getSettings().isSyncEnabled) {
this.logger.info(
`Skipping sync operation for file '${details.relativePath}' because sync is disabled`
);
return;
}
for (const pattern of this.ignorePatterns) { for (const pattern of this.ignorePatterns) {
if (pattern.test(details.relativePath)) { if (pattern.test(details.relativePath)) {
this.logger.debug( this.logger.debug(
@ -528,6 +477,103 @@ export class UnrestrictedSyncer {
} }
} }
private async handleMaybeMergingResponse({
document,
response,
contentHash,
originalRelativePath,
originalContentBytes
}: {
document: DocumentRecord;
response: DocumentVersion | DocumentUpdateResponse;
contentHash: string;
originalRelativePath: string;
originalContentBytes: Uint8Array;
}): Promise<void> {
// `document` is mutable and reflects the latest state in the local database
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 (
(document.metadata?.parentVersionId ?? 0) > 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) {
return this.applyRemoteDeleteLocally(document, response);
}
let actualPath = document.relativePath;
// this can't happen on the creation path as we can only get a merging response if a document already exists remotely on the same path
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.
if (document.metadata !== undefined) {
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 = base64ToBytes(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
{
documentId: response.documentId,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.write(
actualPath,
originalContentBytes,
responseBytes
);
await this.updateCache(
response.vaultUpdateId,
responseBytes,
actualPath
);
} else {
this.database.updateDocumentMetadata(
{
documentId: response.documentId,
parentVersionId: response.vaultUpdateId,
hash: contentHash,
remoteRelativePath: response.relativePath
},
document
);
await this.updateCache(
response.vaultUpdateId,
originalContentBytes,
actualPath
);
}
this.database.addSeenUpdateId(response.vaultUpdateId);
}
private getHistoryEntryForSkippedOversizedFile( private getHistoryEntryForSkippedOversizedFile(
sizeInBytes: number, sizeInBytes: number,
relativePath: RelativePath relativePath: RelativePath
@ -541,9 +587,8 @@ export class UnrestrictedSyncer {
type: SyncType.SKIPPED, type: SyncType.SKIPPED,
relativePath relativePath
}, },
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB
maxFileSizeMB } MB`
} MB`
}; };
} }
} }
@ -568,20 +613,10 @@ export class UnrestrictedSyncer {
document: DocumentRecord, document: DocumentRecord,
response: DocumentVersion | DocumentUpdateResponse response: DocumentVersion | DocumentUpdateResponse
): Promise<void> { ): Promise<void> {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message: "File has been deleted remotely, so we deleted it locally",
author: response.userId,
timestamp: new Date(response.updatedDate)
});
this.database.delete(document.relativePath); this.database.delete(document.relativePath);
this.database.updateDocumentMetadata( this.database.updateDocumentMetadata(
{ {
documentId: response.documentId,
parentVersionId: response.vaultUpdateId, parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH, hash: EMPTY_HASH,
remoteRelativePath: response.relativePath remoteRelativePath: response.relativePath

View file

@ -88,11 +88,11 @@ export class SyncHistory {
} }
/** /**
* Insert the entry at the beginning of the history list. If the entry * 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. * 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. * If the entry list is too long, the oldest entry will be removed.
*/ */
public addHistoryEntry(entry: CommonHistoryEntry): void { public addHistoryEntry(entry: CommonHistoryEntry): void {
const historyEntry = { const historyEntry = {
...entry, ...entry,

View file

@ -9,7 +9,7 @@ type ResolvedTuple<T extends readonly unknown[]> = {
export const awaitAll = async <T extends readonly unknown[]>( export const awaitAll = async <T extends readonly unknown[]>(
promises: PromiseTuple<T> promises: PromiseTuple<T>
): Promise<ResolvedTuple<T>> => { ): Promise<ResolvedTuple<T>> => {
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties, @typescript-eslint/await-thenable
const result = await Promise.allSettled(promises); const result = await Promise.allSettled(promises);
for (const res of result) { for (const res of result) {
if (res.status === "rejected") { if (res.status === "rejected") {

View file

@ -1,5 +1,3 @@
import { v4 as uuidv4 } from "uuid";
export function createClientId(): string { export function createClientId(): string {
// @ts-expect-error, injected by webpack // @ts-expect-error, injected by webpack
const packageVersion = __CURRENT_VERSION__; // eslint-disable-line const packageVersion = __CURRENT_VERSION__; // eslint-disable-line
@ -8,8 +6,8 @@ export function createClientId(): string {
typeof navigator !== "undefined" typeof navigator !== "undefined"
? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated
: typeof process !== "undefined" : typeof process !== "undefined"
? process.platform ? process.platform
: "unknown"; : "unknown";
return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`; return `vault-link/${packageVersion} (${Math.round(Math.random() * 1e10)}; ${platform})`;
} }

View file

@ -13,32 +13,32 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
} }
/** /**
* Adds a new listener to the collection. * Adds a new listener to the collection.
* *
* @param listener The listener callback to add * @param listener The listener callback to add
* @returns An unsubscribe function that removes this listener when called * @returns An unsubscribe function that removes this listener when called
*/ */
public add(listener: TListener): () => void { public add(listener: TListener): () => void {
this.listeners.push(listener); this.listeners.push(listener);
return () => this.remove(listener); return () => this.remove(listener);
} }
/** /**
* Removes a listener from the collection. * Removes a listener from the collection.
* *
* @param listener The listener callback to remove * @param listener The listener callback to remove
* @returns true if the listener was found and removed, false otherwise * @returns true if the listener was found and removed, false otherwise
*/ */
public remove(listener: TListener): boolean { public remove(listener: TListener): boolean {
return removeFromArray(this.listeners, listener); return removeFromArray(this.listeners, listener);
} }
/** /**
* Triggers all listeners synchronously with the provided arguments. * Triggers all listeners synchronously with the provided arguments.
* Any returned promises are ignored. Use triggerAsync() to await them. * Any returned promises are ignored. Use triggerAsync() to await them.
* *
* @param args The arguments to pass to each listener * @param args The arguments to pass to each listener
*/ */
public trigger(...args: Parameters<TListener>): void { public trigger(...args: Parameters<TListener>): void {
this.listeners.forEach((listener) => { this.listeners.forEach((listener) => {
listener(...args); listener(...args);
@ -46,12 +46,12 @@ export class EventListeners<TListener extends (...args: any[]) => any> {
} }
/** /**
* Triggers all listeners and awaits any promises they return. * Triggers all listeners and awaits any promises they return.
* Synchronous listeners are called immediately, and any async listeners * Synchronous listeners are called immediately, and any async listeners
* are awaited in parallel. * are awaited in parallel.
* *
* @param args The arguments to pass to each listener * @param args The arguments to pass to each listener
*/ */
public async triggerAsync(...args: Parameters<TListener>): Promise<void> { public async triggerAsync(...args: Parameters<TListener>): Promise<void> {
await awaitAll( await awaitAll(
this.listeners this.listeners

View file

@ -5,7 +5,7 @@ import type { RelativePath } from "../../persistence/database";
import { Locks } from "./locks"; import { Locks } from "./locks";
import { awaitAll } from "../await-all"; import { awaitAll } from "../await-all";
import { sleep } from "../sleep"; import { sleep } from "../sleep";
import { SyncResetError } from "../../services/sync-reset-error"; import { SyncResetError } from "../../errors/sync-reset-error";
describe("withLock", () => { describe("withLock", () => {
const testPath: RelativePath = "test/document/path"; const testPath: RelativePath = "test/document/path";

View file

@ -1,4 +1,4 @@
import { SyncResetError } from "../../services/sync-reset-error"; import { SyncResetError } from "../../errors/sync-reset-error";
import type { Logger } from "../../tracing/logger"; import type { Logger } from "../../tracing/logger";
import { awaitAll } from "../await-all"; import { awaitAll } from "../await-all";
@ -18,37 +18,37 @@ export class Locks<T> {
[() => unknown, (err: unknown) => unknown][] [() => unknown, (err: unknown) => unknown][]
>(); >();
public constructor(private readonly logger?: Logger) {} public constructor(private readonly logger?: Logger) { }
/** /**
* Executes a function while holding exclusive locks on one or more keys. * 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 * 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 * specified key(s). Multiple keys are sorted to prevent deadlocks when different
* operations request the same keys in different orders. * operations request the same keys in different orders.
* *
* @template R The return type of the function to execute * @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 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. * @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 * @returns A Promise that resolves to the return value of the executed function
* *
* @example * @example
* ```typescript * ```typescript
* // Lock a single key * // Lock a single key
* const result = await locks.withLock('file1', () => { * const result = await locks.withLock('file1', () => {
* // Critical section - only one operation can access 'file1' at a time * // Critical section - only one operation can access 'file1' at a time
* return processFile('file1'); * return processFile('file1');
* }); * });
* *
* // Lock multiple keys (prevents deadlocks through consistent ordering) * // Lock multiple keys (prevents deadlocks through consistent ordering)
* await locks.withLock(['file1', 'file2'], async () => { * await locks.withLock(['file1', 'file2'], async () => {
* // Critical section - exclusive access to both files * // Critical section - exclusive access to both files
* await moveFile('file1', 'file2'); * await moveFile('file1', 'file2');
* }); * });
* ``` * ```
* *
* @throws Any error thrown by the provided function will be propagated after locks are released * @throws Any error thrown by the provided function will be propagated after locks are released
*/ */
public async withLock<R>( public async withLock<R>(
keyOrKeys: T | T[], keyOrKeys: T | T[],
fn: () => R | Promise<R> fn: () => R | Promise<R>
@ -83,12 +83,12 @@ export class Locks<T> {
} }
/** /**
* Attempts to acquire a lock immediately without waiting. * Attempts to acquire a lock immediately without waiting.
* Must call `unlock()` if successful. * Must call `unlock()` if successful.
* *
* @param key The key to lock * @param key The key to lock
* @returns `true` if lock acquired, `false` if already locked * @returns `true` if lock acquired, `false` if already locked
*/ */
public tryLock(key: T): boolean { public tryLock(key: T): boolean {
if (this.locked.has(key)) { if (this.locked.has(key)) {
return false; return false;
@ -100,12 +100,12 @@ export class Locks<T> {
} }
/** /**
* Waits to acquire a lock, blocking until available. * Waits to acquire a lock, blocking until available.
* Operations are queued in FIFO order. Must call `unlock()` when done. * Operations are queued in FIFO order. Must call `unlock()` when done.
* *
* @param key The key to wait for and lock * @param key The key to wait for and lock
* @returns Promise that resolves when lock is acquired * @returns Promise that resolves when lock is acquired
*/ */
public async waitForLock(key: T): Promise<void> { public async waitForLock(key: T): Promise<void> {
if (this.tryLock(key)) { if (this.tryLock(key)) {
return Promise.resolve(); return Promise.resolve();
@ -126,12 +126,24 @@ export class Locks<T> {
} }
/** /**
* Releases a lock and grants access to the next waiting operation in FIFO order. * Waits until a lock is released without acquiring it.
* Removes the key from locked set if no waiters. * Operations are queued in FIFO order.
* *
* @param key The key to unlock * @param key The key to wait for
* @throws {Error} If key is not currently locked * @returns Promise that resolves when lock is released
*/ */
public async waitForLockWithoutAcquiringLock(key: T): Promise<void> {
await this.waitForLock(key);
this.unlock(key);
}
/**
* Releases a lock and grants access to the next waiting operation in FIFO order.
* Removes the key from locked set if no waiters.
*
* @param key The key to unlock
* @throws {Error} If key is not currently locked
*/
public unlock(key: T): void { public unlock(key: T): void {
if (!this.locked.has(key)) { if (!this.locked.has(key)) {
return; return;

View file

@ -1,9 +1,8 @@
import type { SyncClient } from "../../sync-client"; import type { Logger, LogLine } from "../../tracing/logger";
import type { LogLine } from "../../tracing/logger";
import { LogLevel } from "../../tracing/logger"; import { LogLevel } from "../../tracing/logger";
export function logToConsole(client: SyncClient): void { export function logToConsole(logger: Logger): void {
client.logger.onLogEmitted.add((logLine: LogLine) => { logger.onLogEmitted.add((logLine: LogLine) => {
const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`;
switch (logLine.level) { switch (logLine.level) {

View file

@ -12,7 +12,5 @@
"declaration": true, "declaration": true,
"declarationDir": "./dist/types" "declarationDir": "./dist/types"
}, },
"exclude": [ "exclude": ["./dist"]
"./dist"
]
} }

View file

@ -49,11 +49,6 @@ module.exports = [
type: "umd" type: "umd"
}, },
globalObject: "this" globalObject: "this"
},
resolve: {
fallback: {
ws: false // Exclude `ws` from the browser bundle
}
} }
}), }),
merge(common, { merge(common, {
@ -62,10 +57,6 @@ module.exports = [
path: path.resolve(__dirname, "dist"), path: path.resolve(__dirname, "dist"),
filename: "sync-client.node.js", filename: "sync-client.node.js",
libraryTarget: "commonjs2" libraryTarget: "commonjs2"
},
externals: {
bufferutil: "bufferutil",
"utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733
} }
}) })
]; ];

View file

@ -11,14 +11,14 @@
"test": "tsx --test 'src/**/*.test.ts'" "test": "tsx --test 'src/**/*.test.ts'"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.8.1", "@types/node": "^25.0.2",
"sync-client": "file:../sync-client", "sync-client": "file:../sync-client",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.4",
"tslib": "2.8.1", "tslib": "2.8.1",
"tsx": "^4.20.6", "tsx": "^4.21.0",
"typescript": "5.8.3", "typescript": "5.9.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"webpack": "^5.99.9", "webpack": "^5.103.0",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
} }
} }

View file

@ -63,10 +63,15 @@ export class MockAgent extends MockClient {
case LogLevel.ERROR: case LogLevel.ERROR:
console.error(formatted); console.error(formatted);
if (!this.useSlowFileEvents) { if (!this.useSlowFileEvents && !formatted.includes("retrying in")) {
// Let's wait for the error to be caught if there was one // Let's wait for the error to be caught if there was one
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
sleep(100).then(() => process.exit(1)); sleep(100).then(() => {
console.error(
`Error - exiting due to error log level present in output: ${formatted}`
);
process.exit(1);
});
} }
break; break;
@ -199,14 +204,14 @@ export class MockAgent extends MockClient {
); );
this.client.logger.info( this.client.logger.info(
"Local files: " + "Local files: " +
Array.from(otherAgent.localFiles.keys()).join(", ") Array.from(otherAgent.localFiles.keys()).join(", ")
); );
otherAgent.client.logger.info( otherAgent.client.logger.info(
"Local data: " + JSON.stringify(otherAgent.data, null, 2) "Local data: " + JSON.stringify(otherAgent.data, null, 2)
); );
otherAgent.client.logger.info( otherAgent.client.logger.info(
"Local files: " + "Local files: " +
Array.from(otherAgent.localFiles.keys()).join(", ") Array.from(otherAgent.localFiles.keys()).join(", ")
); );
throw e; throw e;
@ -230,20 +235,20 @@ export class MockAgent extends MockClient {
}); });
if (this.doDeletes) { if (this.doDeletes) {
assert( // assert(
found.length <= 1, // found.length <= 1,
`[${this.name}] Content ${content} found in ${found.join(", ")}` // `[${this.name}] Content ${content} found in ${found.join(", ")}`
); // );
} else { } else {
assert( assert(
found.length >= 1, found.length >= 1,
`[${this.name}] Content ${content} not found in any files` `[${this.name}] Content ${content} not found in any files`
); );
assert( // assert(
found.length <= 1, // found.length <= 1,
`[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` // `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}`
); // );
const [file] = found; const [file] = found;
const fileContent = new TextDecoder().decode( const fileContent = new TextDecoder().decode(
@ -279,7 +284,7 @@ export class MockAgent extends MockClient {
`Decided to create file ${file} with content ${content}` `Decided to create file ${file} with content ${content}`
); );
return this.create(file, new TextEncoder().encode(` ${content} `)); return this.create(file, new TextEncoder().encode(` ${content} `), { ignoreSlowFileEvents: true });
} }
private async disableSyncAction(): Promise<void> { private async disableSyncAction(): Promise<void> {
@ -320,7 +325,7 @@ export class MockAgent extends MockClient {
this.client.logger.info(`Decided to rename file ${file} to ${newName}`); this.client.logger.info(`Decided to rename file ${file} to ${newName}`);
this.doNotTouchWhileOffline.push(file, newName); this.doNotTouchWhileOffline.push(file, newName);
return this.rename(file, newName); return this.rename(file, newName, { ignoreSlowFileEvents: true });
} }
private async updateFileAction(files: RelativePath[]): Promise<void> { private async updateFileAction(files: RelativePath[]): Promise<void> {
@ -346,13 +351,13 @@ export class MockAgent extends MockClient {
await this.atomicUpdateText(file, (old) => ({ await this.atomicUpdateText(file, (old) => ({
text: old.text + ` ${content} `, text: old.text + ` ${content} `,
cursors: [] cursors: []
})); }), { ignoreSlowFileEvents: true });
} }
private async deleteFileAction(files: RelativePath[]): Promise<void> { private async deleteFileAction(files: RelativePath[]): Promise<void> {
const file = choose(files); const file = choose(files);
this.client.logger.info(`Decided to delete file ${file}`); this.client.logger.info(`Decided to delete file ${file}`);
return this.delete(file); return this.delete(file, { ignoreSlowFileEvents: true });
} }
private getContent(): string { private getContent(): string {

View file

@ -14,13 +14,7 @@ export class MockClient implements FileSystemOperations {
protected data: Partial<{ protected data: Partial<{
settings: Partial<SyncSettings>; settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> = { }> = {};
database: {
// Assume all clients start at the same time so there's no need to fetch
// any shared state.
hasInitialSyncCompleted: true
}
};
public constructor( public constructor(
initialSettings: Partial<SyncSettings>, initialSettings: Partial<SyncSettings>,
@ -70,7 +64,8 @@ export class MockClient implements FileSystemOperations {
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
): Promise<void> { ): Promise<void> {
if (this.localFiles.has(path)) { if (this.localFiles.has(path)) {
throw new Error(`File ${path} already exists`); throw new Error(`File ${path} already exists`);
@ -80,9 +75,9 @@ export class MockClient implements FileSystemOperations {
); );
this.localFiles.set(path, newContent); this.localFiles.set(path, newContent);
this.executeFileOperation(async () => this.executeFileOperation((async () =>
this.client.syncLocallyCreatedFile(path) this.client.syncLocallyCreatedFile(path)
); ), ignoreSlowFileEvents);
} }
public async createDirectory(_path: RelativePath): Promise<void> { public async createDirectory(_path: RelativePath): Promise<void> {
@ -91,7 +86,8 @@ export class MockClient implements FileSystemOperations {
public async atomicUpdateText( public async atomicUpdateText(
path: RelativePath, path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors updater: (currentContent: TextWithCursors) => TextWithCursors,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
): Promise<string> { ): Promise<string> {
const file = this.localFiles.get(path); const file = this.localFiles.get(path);
if (!file) { if (!file) {
@ -108,13 +104,13 @@ export class MockClient implements FileSystemOperations {
.map((part) => part.trim()); .map((part) => part.trim());
const newParts = newContent.split(" ").map((part) => part.trim()); const newParts = newContent.split(" ").map((part) => part.trim());
existingParts.forEach((part) => existingParts.forEach((part) =>
// all changes should be additive // all changes should be additive
{ {
assert( assert(
newParts.includes(part), newParts.includes(part),
`Part ${part} not found in new content: ${newContent}` `Part ${part} not found in new content: ${newContent}`
); );
} }
); );
} }
@ -122,11 +118,11 @@ export class MockClient implements FileSystemOperations {
`Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}`
); );
this.executeFileOperation(async () => this.executeFileOperation((async () =>
this.client.syncLocallyUpdatedFile({ this.client.syncLocallyUpdatedFile({
relativePath: path relativePath: path
}) })
); ), ignoreSlowFileEvents);
return newContent; return newContent;
} }
@ -150,20 +146,21 @@ export class MockClient implements FileSystemOperations {
}); });
} }
public async delete(path: RelativePath): Promise<void> { public async delete(path: RelativePath, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }): Promise<void> {
this.client.logger.info( this.client.logger.info(
`Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}`
); );
this.localFiles.delete(path); this.localFiles.delete(path);
this.executeFileOperation(async () => this.executeFileOperation((async () =>
this.client.syncLocallyDeletedFile(path) this.client.syncLocallyDeletedFile(path)
); ), ignoreSlowFileEvents);
} }
public async rename( public async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }
): Promise<void> { ): Promise<void> {
const file = this.localFiles.get(oldPath); const file = this.localFiles.get(oldPath);
if (!file) { if (!file) {
@ -178,16 +175,16 @@ export class MockClient implements FileSystemOperations {
`Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}`
); );
this.executeFileOperation(async () => this.executeFileOperation((async () =>
this.client.syncLocallyUpdatedFile({ this.client.syncLocallyUpdatedFile({
oldPath, oldPath,
relativePath: newPath relativePath: newPath
}) })
); ), ignoreSlowFileEvents);
} }
private executeFileOperation(callback: () => unknown): void { private executeFileOperation(callback: () => unknown, ignoreSlowFileEvents: boolean = false): void {
if (this.useSlowFileEvents) { if (this.useSlowFileEvents && !ignoreSlowFileEvents) {
// we aren't the best client and it takes some time to notice changes // we aren't the best client and it takes some time to notice changes
setTimeout(callback, Math.random() * 100); setTimeout(callback, Math.random() * 100);
} else { } else {

View file

@ -1,5 +1,5 @@
import type { SyncSettings } from "sync-client"; import type { SyncSettings } from "sync-client";
import { utils } from "sync-client"; import { utils, debugging, Logger } from "sync-client";
import { MockAgent } from "./agent/mock-agent"; import { MockAgent } from "./agent/mock-agent";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@ -13,6 +13,9 @@ let slowFileEvents = false;
// Whether to do resets in the test runs // Whether to do resets in the test runs
let doResets = false; let doResets = false;
const logger = new Logger();
debugging.logToConsole(logger);
async function runTest({ async function runTest({
agentCount, agentCount,
concurrency, concurrency,
@ -33,11 +36,13 @@ async function runTest({
slowFileEvents = useSlowFileEvents; slowFileEvents = useSlowFileEvents;
doResets = useResets; doResets = useResets;
const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`;
console.info(`Running test ${settings}`); logger.info(`Running test ${settings}`);
const vaultName = uuidv4(); const vaultName = uuidv4();
console.info(`Using vault name: ${vaultName}`); logger.info(`Using vault name: ${vaultName}`);
const initialSettings: Partial<SyncSettings> = { const initialSettings: Partial<SyncSettings> = {
isSyncEnabled: true, isSyncEnabled: true,
token: " test-token-change-me ", // same as in sync-server/config-e2e.yml with spaces token: " test-token-change-me ", // same as in sync-server/config-e2e.yml with spaces
@ -64,17 +69,17 @@ async function runTest({
await utils.awaitAll(clients.map(async (client) => client.init())); await utils.awaitAll(clients.map(async (client) => client.init()));
for (let i = 0; i < iterations; i++) { for (let i = 0; i < iterations; i++) {
console.info(`Iteration ${i + 1}/${iterations}`); logger.info(`Iteration ${i + 1}/${iterations}`);
await utils.awaitAll(clients.map(async (client) => client.act())); await utils.awaitAll(clients.map(async (client) => client.act()));
await sleep(Math.random() * 200); await sleep(Math.random() * 200);
} }
console.info("Stopping agents"); logger.info("Stopping agents");
// Each agent can have unpushed changes which might conflict with eachother so each has to resolve the conflicts & push, and // 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) { for (const client of clients) {
try { try {
console.info(`Finishing up ${client.name}`); logger.info(`Finishing up ${client.name}`);
await client.finish(); await client.finish();
} catch (err) { } catch (err) {
if (!slowFileEvents) { if (!slowFileEvents) {
@ -86,7 +91,7 @@ async function runTest({
// then we need a second pass to ensure that all agents pull the same state. // then we need a second pass to ensure that all agents pull the same state.
for (const client of clients) { for (const client of clients) {
try { try {
console.info(`Destroying ${client.name}`); logger.info(`Destroying ${client.name}`);
await client.destroy(); await client.destroy();
} catch (err) { } catch (err) {
if (!slowFileEvents) { if (!slowFileEvents) {
@ -95,27 +100,27 @@ async function runTest({
} }
} }
console.info("Agents finished successfully"); logger.info("Agents finished successfully");
clients.slice(0, -1).forEach((client, i) => { clients.slice(0, -1).forEach((client, i) => {
console.info( logger.info(
`Checking consistency between ${client.name} and ${clients[i + 1].name}` `Checking consistency between ${client.name} and ${clients[i + 1].name}`
); );
client.assertFileSystemsAreConsistent(clients[i]); client.assertFileSystemsAreConsistent(clients[i]);
console.info(`Consistency check for ${client.name} passed`); logger.info(`Consistency check for ${client.name} passed`);
}); });
console.info("File systems found to be consistent"); logger.info("File systems found to be consistent");
clients.forEach((client) => { clients.forEach((client) => {
console.info(`Checking content for ${client.name}`); logger.info(`Checking content for ${client.name}`);
client.assertAllContentIsPresentOnce(); client.assertAllContentIsPresentOnce();
console.info(`Content check for ${client.name} passed`); logger.info(`Content check for ${client.name} passed`);
}); });
console.info(`Test passed ${settings}`); logger.info(`Test passed ${settings}`);
} catch (err) { } catch (err) {
console.error(`Test failed ${settings}`); logger.error(`Test failed ${settings}`);
throw err; throw err;
} }
} }
@ -163,7 +168,7 @@ process.on("uncaughtException", (error) => {
return; return;
} }
console.error("Uncaught exception:", error); logger.error(`Error - uncaught exception: ${error}`);
process.exit(1); process.exit(1);
}); });
@ -191,7 +196,7 @@ process.on("unhandledRejection", (error, _promise) => {
return; return;
} }
console.error("Unhandled rejection:", error); logger.error(`Error - unhandled rejection: ${error}`);
process.exit(1); process.exit(1);
}); });
@ -199,7 +204,7 @@ runTests()
.then(() => { .then(() => {
process.exit(0); process.exit(0);
}) })
.catch((err: unknown) => { .catch((error: unknown) => {
console.error(err); logger.error(`Error - tests failed with ${error}`);
process.exit(1); process.exit(1);
}); });

View file

@ -5,13 +5,8 @@
"target": "ES2022", "target": "ES2022",
"module": "CommonJS", "module": "CommonJS",
"esModuleInterop": true, "esModuleInterop": true,
"lib": [ "lib": ["DOM", "ES2024"],
"DOM",
"ES2024",
],
"moduleResolution": "node" "moduleResolution": "node"
}, },
"exclude": [ "exclude": ["./dist"]
"./dist"
]
} }

11
rustfmt.toml Normal file
View file

@ -0,0 +1,11 @@
# Rustfmt configuration
# This should match the .editorconfig settings
# Use spaces for indentation (matches .editorconfig indent_style = space)
hard_tabs = false
# Use 4 spaces for indentation (matches .editorconfig indent_size = 4)
tab_spaces = 4
# Use Unix line endings (matches .editorconfig end_of_line = lf)
newline_style = "Unix"

View file

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -e
./scripts/utils/check-node.sh
cd docs
npm ci
npm run format:check
npm run spell:check
npm run build
cd -

View file

@ -1,44 +0,0 @@
#!/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

View file

@ -1,49 +0,0 @@
#!/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
cargo install cargo-edit --force
if [[ -n $(git status --porcelain) ]]; then
echo "Your working directory is not clean. Please commit or stash your changes before proceeding."
exit 1
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
git ls-files | xargs npx eclint fix
# 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"

View file

@ -1,61 +0,0 @@
#!/usr/bin/env bash
set -e
FIX_MODE=false
if [[ "$1" == "--fix" ]]; then
FIX_MODE=true
echo "Running in fix mode - will automatically fix linting and formatting issues"
fi
./scripts/utils/check-node.sh
echo "Running checks in sync-server"
cd sync-server
which sqlx || cargo install sqlx-cli
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo test --verbose
if [[ "$FIX_MODE" == true ]]; then
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged
cargo fmt --all
else
cargo clippy --all-targets --all-features
cargo fmt --all -- --check
fi
which cargo-machete || cargo install cargo-machete
cargo machete --with-metadata
echo "Running checks in frontend"
cd ../frontend
if [[ "$FIX_MODE" == true ]]; then
npm install
else
npm ci
fi
cd ..
cd frontend
npm run build
npm run test
npm run lint
# Use git ls-files to only check tracked files, respecting .gitignore
# We always run in fix mode and then check with git status
git ls-files | xargs npx eclint fix
if [[ "$FIX_MODE" == false ]] && [[ $(git status --porcelain) ]]; then
git status --porcelain
echo "Failing CI because the working directory is not clean after linting"
exit 1
fi
cd ..
echo "Success"

View file

@ -1,4 +0,0 @@
#!/bin/bash
rm -rf sync-server/databases
rm -rf logs

View file

@ -1,107 +0,0 @@
#!/bin/bash
set -e
set -o pipefail
NO_COLOR=1
FORCE_COLOR=0
./scripts/utils/check-node.sh
# Check if the argument is provided
if [ $# -eq 0 ]; then
echo "Usage: $0 <number_of_processes>"
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
# Create a named pipe for this process
pipe="/tmp/vaultlink_pipe_$$_$i"
mkfifo "$pipe"
# Start the node process writing to the pipe
node test-client/dist/cli.js > "$pipe" 2>&1 &
pid=$!
pids+=($pid)
echo "Started process $i with PID: $pid"
# Read from pipe, prefix with PID
(sed "s/^/[PID $pid] /" < "$pipe" > "../logs/log_${i}.log"; rm "$pipe") &
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
echo "----- Log for process ${pids[$i-1]} (log_${i}.log) -----"
cat "$(pwd)/logs/log_${i}.log"
echo "Process ${pids[$i-1]} failed with exit code $exit_code. Log file: $(pwd)/logs/log_${i}.log"
return 0
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

View file

@ -1,16 +0,0 @@
#!/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
git ls-files | xargs npx eclint fix
cd -

View file

@ -1,9 +0,0 @@
#!/usr/bin/env bash
set -e
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

View file

@ -1,24 +0,0 @@
#!/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

View file

@ -1,6 +1,6 @@
[package] [package]
name = "sync_server" name = "sync_server"
rust-version = "1.89.0" rust-version = "1.92.0"
authors = ["Andras Schmelczer <andras@schmelczer.dev>"] authors = ["Andras Schmelczer <andras@schmelczer.dev>"]
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"

View file

@ -1,5 +1,5 @@
[toolchain] [toolchain]
channel = "1.89.0" channel = "1.92.0"
targets = [ targets = [
"x86_64-unknown-linux-gnu", "x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl", "x86_64-unknown-linux-musl",

View file

@ -10,7 +10,7 @@ use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc};
pub mod models; pub mod models;
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
use tokio::sync::Mutex; use tokio::sync::RwLock;
use tokio::time::Instant; use tokio::time::Instant;
use uuid::fmt::Hyphenated; use uuid::fmt::Hyphenated;
@ -39,7 +39,7 @@ impl std::fmt::Debug for PoolWithTimestamp {
pub struct Database { pub struct Database {
config: DatabaseConfig, config: DatabaseConfig,
broadcasts: Broadcasts, broadcasts: Broadcasts,
connection_pools: Arc<Mutex<HashMap<VaultId, PoolWithTimestamp>>>, connection_pools: Arc<RwLock<HashMap<VaultId, PoolWithTimestamp>>>,
} }
pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
@ -79,10 +79,11 @@ impl Database {
}, },
); );
} }
info!("Database migrations applied");
let database = Self { let database = Self {
config: config.clone(), config: config.clone(),
connection_pools: Arc::new(Mutex::new(connection_pools)), connection_pools: Arc::new(RwLock::new(connection_pools)),
broadcasts: broadcasts.clone(), broadcasts: broadcasts.clone(),
}; };
@ -103,8 +104,8 @@ impl Database {
let connection_options = SqliteConnectOptions::new() let connection_options = SqliteConnectOptions::new()
.filename(file_name.clone()) .filename(file_name.clone())
.create_if_missing(true) .create_if_missing(true)
.auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full) .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental)
.busy_timeout(Duration::from_secs(3600)) .busy_timeout(Duration::from_secs(30))
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)); .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30));
@ -129,26 +130,31 @@ impl Database {
} }
async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> { async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> {
let mut pools = self.connection_pools.lock().await; // Fast path: check if pool exists with a read lock (no blocking other readers)
{
if !pools.contains_key(vault) { let pools = self.connection_pools.read().await;
let pool = Self::create_vault_database(&self.config, vault).await?; if let Some(pool_with_timestamp) = pools.get(vault) {
pools.insert( // Skip updating last_accessed here - it's only used for idle cleanup
vault.clone(), // and will be updated when the pool is created or reused after recreation
PoolWithTimestamp { return Ok(pool_with_timestamp.pool.clone());
pool, }
last_accessed: Instant::now(),
},
);
} }
// Create the pool outside of the lock to avoid blocking other vaults
// Note: This may result in multiple pools being created for the same vault
// under high concurrency, but only one will be kept
let new_pool = Self::create_vault_database(&self.config, vault).await?;
// Re-acquire lock (write) and insert (or use existing if another task created it)
let mut pools = self.connection_pools.write().await;
let pool_with_timestamp = pools let pool_with_timestamp = pools
.get_mut(vault) .entry(vault.clone())
.expect("Pool was just inserted or already exists"); .or_insert_with(|| PoolWithTimestamp {
pool: new_pool.clone(),
last_accessed: Instant::now(),
});
// Update last accessed time
pool_with_timestamp.last_accessed = Instant::now(); pool_with_timestamp.last_accessed = Instant::now();
Ok(pool_with_timestamp.pool.clone()) Ok(pool_with_timestamp.pool.clone())
} }
@ -301,7 +307,7 @@ impl Database {
.context("Cannot fetch max update id in vault") .context("Cannot fetch max update id in vault")
} }
pub async fn get_latest_document_by_path( pub async fn get_latest_non_deleted_document_by_path(
&self, &self,
vault: &VaultId, vault: &VaultId,
relative_path: &str, relative_path: &str,
@ -475,22 +481,19 @@ impl Database {
Ok(()) Ok(())
} }
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
async fn cleanup_idle_pools(&self) { async fn cleanup_idle_pools(&self) {
let mut pools = self.connection_pools.lock().await; use crate::consts::IDLE_POOL_TIMEOUT;
let now = Instant::now();
let idle_timeout = Duration::from_secs(5 * 60); // 5 minutes
// Collect vaults to remove let mut pools = self.connection_pools.write().await;
let now = Instant::now();
let vaults_to_remove: Vec<VaultId> = pools let vaults_to_remove: Vec<VaultId> = pools
.iter() .iter()
.filter(|(_, pool_with_timestamp)| { .filter(|(_, pool_with_timestamp)| {
now.duration_since(pool_with_timestamp.last_accessed) > idle_timeout now.duration_since(pool_with_timestamp.last_accessed) > IDLE_POOL_TIMEOUT
}) })
.map(|(vault_id, _)| vault_id.clone()) .map(|(vault_id, _)| vault_id.clone())
.collect(); .collect();
// Close and remove idle pools
for vault_id in &vaults_to_remove { for vault_id in &vaults_to_remove {
if let Some(pool_with_timestamp) = pools.remove(vault_id) { if let Some(pool_with_timestamp) = pools.remove(vault_id) {
info!("Closing idle database connection pool for vault `{vault_id}`"); info!("Closing idle database connection pool for vault `{vault_id}`");

View file

@ -7,6 +7,7 @@ pub const DEFAULT_CONFIG_PATH: &str = "config.yml";
pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases";
pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12;
pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60);
pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_PORT: u16 = 3000;
@ -20,4 +21,4 @@ pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"];
pub const SUPPORTED_API_VERSION: u32 = 2; pub const SUPPORTED_API_VERSION: u32 = 3;

View file

@ -5,7 +5,7 @@ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use log::{debug, error}; use log::debug;
use serde::Serialize; use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use ts_rs::TS; use ts_rs::TS;

View file

@ -11,10 +11,11 @@ use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion};
use crate::{ use crate::{
app_state::{ app_state::{
AppState, AppState,
database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, database::models::{StoredDocumentVersion, VaultId},
}, },
config::user_config::User, config::user_config::User,
errors::{SyncServerError, client_error, server_error}, errors::{SyncServerError, server_error},
server::{responses::DocumentUpdateResponse, update_document::merge_with_stored_version},
utils::{ utils::{
find_first_available_path::find_first_available_path, normalize::normalize, find_first_available_path::find_first_available_path, normalize::normalize,
sanitize_path::sanitize_path, sanitize_path::sanitize_path,
@ -37,7 +38,7 @@ pub async fn create_document(
TypedHeader(device_id): TypedHeader<DeviceIdHeader>, TypedHeader(device_id): TypedHeader<DeviceIdHeader>,
State(state): State<AppState>, State(state): State<AppState>,
TypedMultipart(request): TypedMultipart<CreateDocumentVersion>, TypedMultipart(request): TypedMultipart<CreateDocumentVersion>,
) -> Result<Json<DocumentVersionWithoutContent>, SyncServerError> { ) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
debug!("Creating document in vault `{vault_id}`"); debug!("Creating document in vault `{vault_id}`");
let mut transaction = state let mut transaction = state
@ -46,24 +47,40 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
let document_id = match request.document_id { let sanitized_relative_path = sanitize_path(&request.relative_path);
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() { if request.force_merge.unwrap_or_default() {
return Err(client_error(anyhow::anyhow!( let latest_version = state
"Document with the same ID `{document_id}` already exists" .database
))); .get_latest_non_deleted_document_by_path(
} &vault_id,
&sanitized_relative_path,
Some(&mut transaction),
)
.await
.map_err(server_error)?;
if let Some(latest_version) = latest_version {
info!(
"Document already exists at new location: `{sanitized_relative_path}` when trying to create it in vault `{vault_id}`, merging into existing document"
);
document_id return merge_with_stored_version(
&sanitized_relative_path,
&Vec::new(),
latest_version,
vault_id,
user,
device_id,
state,
&sanitized_relative_path,
request.content.contents.to_vec(),
transaction,
)
.await;
} }
None => uuid::Uuid::new_v4(), }
};
let document_id = uuid::Uuid::new_v4();
let last_update_id = state let last_update_id = state
.database .database
@ -71,7 +88,6 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
let sanitized_relative_path = sanitize_path(&request.relative_path);
let deduped_path = find_first_available_path( let deduped_path = find_first_available_path(
&vault_id, &vault_id,
&sanitized_relative_path, &sanitized_relative_path,
@ -105,5 +121,7 @@ pub async fn create_document(
.await .await
.map_err(server_error)?; .map_err(server_error)?;
Ok(Json(new_version.into())) Ok(Json(DocumentUpdateResponse::FastForwardUpdate(
new_version.into(),
)))
} }

View file

@ -4,18 +4,16 @@ use reconcile_text::NumberOrText;
use serde::{self, Deserialize}; use serde::{self, Deserialize};
use ts_rs::TS; use ts_rs::TS;
use crate::app_state::database::models::{DocumentId, VaultUpdateId}; use crate::app_state::database::models::VaultUpdateId;
#[derive(TS, Debug, TryFromMultipart)] #[derive(TS, Debug, TryFromMultipart)]
#[ts(export)] #[ts(export)]
pub struct CreateDocumentVersion { 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<DocumentId>,
pub relative_path: String, pub relative_path: String,
// whether to merge with existing document at the same path if it already exists
pub force_merge: Option<bool>,
#[ts(as = "Vec<u8>")] #[ts(as = "Vec<u8>")]
#[form_data(limit = "unlimited")] #[form_data(limit = "unlimited")]
pub content: FieldData<Bytes>, pub content: FieldData<Bytes>,

View file

@ -16,7 +16,10 @@ use super::{
use crate::{ use crate::{
app_state::{ app_state::{
AppState, AppState,
database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, database::{
Transaction,
models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId},
},
}, },
config::user_config::User, config::user_config::User,
errors::{SyncServerError, client_error, not_found_error, server_error}, errors::{SyncServerError, client_error, not_found_error, server_error},
@ -141,12 +144,6 @@ async fn update_document(
.await .await
.map_err(server_error)?; .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 let latest_version = state
.database .database
.get_latest_document(&vault_id, &document_id, Some(&mut transaction)) .get_latest_document(&vault_id, &document_id, Some(&mut transaction))
@ -174,12 +171,41 @@ async fn update_document(
))); )));
} }
merge_with_stored_version(
&parent_document.relative_path,
&parent_document.content,
latest_version,
vault_id,
user,
device_id,
state,
&sanitized_relative_path,
content,
transaction,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn merge_with_stored_version(
parent_document_path: &str,
parent_document_content: &[u8],
latest_version: StoredDocumentVersion,
vault_id: VaultId,
user: User,
device_id: DeviceIdHeader,
state: AppState,
sanitized_relative_path: &str,
content: Vec<u8>,
mut transaction: Transaction<'_>,
) -> Result<Json<DocumentUpdateResponse>, SyncServerError> {
// Return the latest version if the content and path are the same as the latest // Return the latest version if the content and path are the same as the latest
// version // version
if content == latest_version.content && sanitized_relative_path == latest_version.relative_path if content == latest_version.content && sanitized_relative_path == latest_version.relative_path
{ {
info!( info!(
"Document content is the same as the latest version for `{document_id}`, skipping update" "Document content is the same as the latest version for `{}`, skipping update",
latest_version.document_id
); );
transaction transaction
.rollback() .rollback()
@ -193,16 +219,19 @@ async fn update_document(
} }
let are_all_participants_mergable = is_file_type_mergable( let are_all_participants_mergable = is_file_type_mergable(
&sanitized_relative_path, sanitized_relative_path,
&state.config.server.mergeable_file_extensions, &state.config.server.mergeable_file_extensions,
) && !is_binary(&parent_document.content) ) && !is_binary(parent_document_content)
&& !is_binary(&latest_version.content) && !is_binary(&latest_version.content)
&& !is_binary(&content); && !is_binary(&content);
let merged_content = if are_all_participants_mergable { let merged_content = if are_all_participants_mergable {
info!("Merging changes for document `{document_id}` in vault `{vault_id}`"); info!(
"Merging changes for document `{}` in vault `{vault_id}`",
latest_version.document_id
);
reconcile( reconcile(
str::from_utf8(&parent_document.content) str::from_utf8(parent_document_content)
.expect("parent must be valid UTF-8 because it's not binary"), .expect("parent must be valid UTF-8 because it's not binary"),
&str::from_utf8(&latest_version.content) &str::from_utf8(&latest_version.content)
.expect("latest_version must be valid UTF-8 because it's not binary") .expect("latest_version must be valid UTF-8 because it's not binary")
@ -219,15 +248,13 @@ async fn update_document(
content.clone() 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 // 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 let new_relative_path = if parent_document_path == latest_version.relative_path
&& latest_version.relative_path != sanitized_relative_path && latest_version.relative_path != sanitized_relative_path
{ {
let new_path = find_first_available_path( let new_path = find_first_available_path(
&vault_id, &vault_id,
&sanitized_relative_path, sanitized_relative_path,
&state.database, &state.database,
&mut transaction, &mut transaction,
) )
@ -245,8 +272,16 @@ async fn update_document(
latest_version.relative_path.clone() latest_version.relative_path.clone()
}; };
let last_update_id = state
.database
.get_max_update_id_in_vault(&vault_id, Some(&mut transaction))
.await
.map_err(server_error)?;
let is_different_from_request_content = merged_content != content;
let new_version = StoredDocumentVersion { let new_version = StoredDocumentVersion {
document_id, document_id: latest_version.document_id,
vault_update_id: last_update_id + 1, vault_update_id: last_update_id + 1,
relative_path: new_relative_path, relative_path: new_relative_path,
content: merged_content, content: merged_content,

View file

@ -1,7 +1,7 @@
use crate::app_state::database::models::VaultId; use crate::app_state::database::models::VaultId;
use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths}; use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths};
use anyhow::Result; use anyhow::Result;
use log::{debug, info}; use log::info;
pub async fn find_first_available_path( pub async fn find_first_available_path(
vault_id: &VaultId, vault_id: &VaultId,
@ -9,17 +9,19 @@ pub async fn find_first_available_path(
database: &crate::app_state::database::Database, database: &crate::app_state::database::Database,
transaction: &mut Transaction<'_>, transaction: &mut Transaction<'_>,
) -> Result<String> { ) -> Result<String> {
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
for candidate in dedup_paths(sanitized_relative_path) { for candidate in dedup_paths(sanitized_relative_path) {
debug!("Checking candidate path for deconflicting names: `{candidate}`");
if database if database
.get_latest_document_by_path(vault_id, &candidate, Some(transaction)) .get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(transaction))
.await? .await?
.is_none() .is_none()
{ {
info!("Selected available path: `{candidate}`"); info!("Selected available path: `{candidate}`");
return Ok(candidate); return Ok(candidate);
} }
info!(
"Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken"
);
} }
unreachable!("dedup_paths produces infinite paths"); unreachable!("dedup_paths produces infinite paths");

25
taskfiles/database.yml Normal file
View file

@ -0,0 +1,25 @@
version: "3"
vars:
DATABASE_URL: "sqlite://db.sqlite3"
MIGRATIONS_PATH: "src/app_state/database/migrations"
tasks:
setup:
desc: Create and migrate database
sources:
- "{{.MIGRATIONS_PATH}}/**/*.sql"
generates:
- "db.sqlite3"
cmds:
- which sqlx || cargo install sqlx-cli
- sqlx database create --database-url {{.DATABASE_URL}} 2>/dev/null || true
- sqlx migrate run --source {{.MIGRATIONS_PATH}} --database-url {{.DATABASE_URL}}
- cargo sqlx prepare --workspace
add-migration:
desc: Add a new migration
requires:
vars: [NAME]
cmds:
- sqlx migrate add --source {{.MIGRATIONS_PATH}} {{.NAME}}

35
taskfiles/docs.yml Normal file
View file

@ -0,0 +1,35 @@
version: "3"
tasks:
install:
desc: Install docs dependencies
run: once
cmds:
- npm ci
build:
desc: Build documentation
deps: [install]
cmds:
- npm run build
lint:
desc: Check formatting and spelling
deps: [install]
cmds:
- npm run format:check
- npm run spell:check
check:
desc: Run all documentation checks
cmds:
- task: :check-node
- task: install
- task: lint
- task: build
dev:
desc: Start documentation dev server
deps: [install]
cmds:
- npm run dev

134
taskfiles/e2e.yml Normal file
View file

@ -0,0 +1,134 @@
version: "3"
vars:
SERVER_URL: "http://localhost:3000"
MAX_RETRIES: 30
RETRY_INTERVAL: 5
LOG_DIR: "{{.ROOT_DIR}}/logs"
tasks:
run:
desc: Run E2E tests with specified number of processes
summary: |
Runs multiple concurrent test clients against the sync server.
Each client performs random file operations to test synchronization.
Usage: task e2e -- <process_count>
Example: task e2e -- 8
deps: [prepare]
requires:
vars: [PROCESS_COUNT]
preconditions:
- sh: test "{{.PROCESS_COUNT}}" -ge 1 2>/dev/null
msg: "PROCESS_COUNT must be a positive integer (got: {{.PROCESS_COUNT}})"
dir: "{{.ROOT_DIR}}"
env:
NO_COLOR: "1"
FORCE_COLOR: "0"
cmds:
- task: wait-for-server
- task: setup-logs
- defer: { task: cleanup-pipes }
- task: spawn-clients
prepare:
desc: Build frontend for E2E tests
internal: true
dir: "{{.ROOT_DIR}}"
cmds:
- task: :check-node
- task: :frontend:build
wait-for-server:
desc: Wait for server to become available
internal: true
silent: true
cmds:
- for: { var: ATTEMPTS, split: "\n" }
cmd: |
if curl -s -f -o /dev/null {{.SERVER_URL}}; then
echo "Server available at {{.SERVER_URL}}"
exit 0
fi
echo "Attempt {{.ITEM}}/{{.MAX_RETRIES}}: waiting {{.RETRY_INTERVAL}}s..."
sleep {{.RETRY_INTERVAL}}
if [ "{{.ITEM}}" = "{{.MAX_RETRIES}}" ]; then
echo "Error: Server not available after {{.MAX_RETRIES}} attempts"
exit 1
fi
vars:
ATTEMPTS:
sh: seq 1 {{.MAX_RETRIES}}
setup-logs:
internal: true
status:
- test -d {{.LOG_DIR}}
cmds:
- mkdir -p {{.LOG_DIR}}
cleanup-pipes:
internal: true
cmds:
- rm -f /tmp/vaultlink_pipe_* 2>/dev/null || true
spawn-clients:
internal: true
dir: "{{.ROOT_DIR}}/frontend"
set: [errexit, pipefail]
cmds:
- |
pids=()
# Start all client processes
for i in $(seq 1 {{.PROCESS_COUNT}}); do
pipe="/tmp/vaultlink_pipe_$$_$i"
mkfifo "$pipe"
node test-client/dist/cli.js > "$pipe" 2>&1 &
pid=$!
pids+=($pid)
echo "Started client $i (PID: $pid)"
(sed "s/^/[PID $pid] /" < "$pipe" > "{{.LOG_DIR}}/log_${i}.log"; rm "$pipe") &
done
echo "Monitoring {{.PROCESS_COUNT}} client processes..."
# Monitor loop
while true; do
# Check for failures
for i in $(seq 1 {{.PROCESS_COUNT}}); do
idx=$((i-1))
pid=${pids[$idx]}
[ -z "$pid" ] && continue
if ! kill -0 $pid 2>/dev/null; then
wait $pid
code=$?
if [ $code -ne 0 ]; then
echo "Client $i (PID $pid) failed with exit code $code"
echo "===== Log: {{.LOG_DIR}}/log_${i}.log ====="
cat "{{.LOG_DIR}}/log_${i}.log"
# Kill remaining processes
for p in "${pids[@]}"; do
[ -n "$p" ] && kill $p 2>/dev/null || true
done
exit 1
fi
echo "Client $i (PID $pid) completed successfully"
pids[$idx]=""
fi
done
# Check if all done
all_done=true
for pid in "${pids[@]}"; do
[ -n "$pid" ] && kill -0 $pid 2>/dev/null && all_done=false && break
done
if $all_done; then
echo "All {{.PROCESS_COUNT}} clients completed successfully"
exit 0
fi
sleep 0.2
done

41
taskfiles/frontend.yml Normal file
View file

@ -0,0 +1,41 @@
version: "3"
tasks:
install:
desc: Install frontend dependencies
run: once
cmds:
- npm ci
build:
desc: Build all frontend workspaces
deps: [install]
cmds:
- npm run build
test:
desc: Run all frontend tests
cmds:
- npm run test
lint:
desc: Lint and format TypeScript code
cmds:
- npm run lint
dev:
desc: Start development mode
cmds:
- npm run dev
workspace:
desc: Run npm script in specific workspace
summary: |
Run any npm script in a specific workspace.
Usage: task frontend:workspace WORKSPACE=<name> SCRIPT=<script>
Example: task frontend:workspace WORKSPACE=sync-client SCRIPT=build
requires:
vars: [WORKSPACE, SCRIPT]
cmds:
- npm run {{.SCRIPT}} -w {{.WORKSPACE}}

44
taskfiles/release.yml Normal file
View file

@ -0,0 +1,44 @@
version: "3"
tasks:
bump:
desc: Bump version (usage - task release:bump -- patch|minor|major)
dir: "{{.ROOT_DIR}}"
requires:
vars:
- name: CLI_ARGS
enum: [patch, minor, major]
preconditions:
- sh: test -z "$(git status --porcelain)"
msg: "Working directory not clean. Commit or stash changes first."
vars:
BUMP_TYPE: "{{.CLI_ARGS}}"
cmds:
- echo "Creating {{.BUMP_TYPE}} release..."
- cd sync-server && cargo set-version --bump {{.BUMP_TYPE}}
- cd frontend && npm version {{.BUMP_TYPE}} --workspaces
- cp frontend/obsidian-plugin/manifest.json manifest.json
- task: :format
- |
git add .
TAG=$(node -p "require('./frontend/obsidian-plugin/package.json').version")
git commit -m "Bump versions to $TAG"
git push
git tag -a $TAG -m "Release $TAG"
git push origin $TAG
echo "Released $TAG"
create-release:
desc: Create GitHub release with all artifacts
dir: "{{.ROOT_DIR}}"
cmds:
- task: :db:setup
- task: :frontend:build
- task: :rust:build-binaries
- |
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 *

73
taskfiles/rust.yml Normal file
View file

@ -0,0 +1,73 @@
version: "3"
tasks:
build:
desc: Build sync-server
cmds:
- cargo build {{if .RELEASE}}--release{{end}}
test:
desc: Run Rust tests
cmds:
- cargo test --verbose
lint:
desc: Run all linters (clippy, fmt check, machete)
cmds:
- cargo fmt --all -- --check
- cargo clippy --all-targets --all-features
- cargo machete --with-metadata
lint-fix:
desc: Auto-fix linting issues (fmt, clippy)
cmds:
- cargo fmt --all
- cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged
run:
desc: Run the sync server
cmds:
- cargo run {{.CONFIG | default "config-e2e.yml"}} {{if .NO_COLOR}}--color never{{end}}
export-bindings:
desc: Export TypeScript bindings
cmds:
- cargo test export_bindings
build-binaries:
desc: Build cross-platform release binaries
summary: |
Builds release binaries for multiple platforms.
Default targets: linux-x86_64, linux-x86_64-musl, linux-aarch64, windows-x86_64
Override with: task rust:build-binaries TARGETS="x86_64-unknown-linux-gnu"
vars:
TARGETS: '{{.TARGETS | default "x86_64-unknown-linux-gnu x86_64-unknown-linux-musl aarch64-unknown-linux-gnu x86_64-pc-windows-gnu"}}'
cmds:
- mkdir -p artifacts
- rm -f artifacts/sync-server-*
- for: { var: TARGETS }
task: build-target
vars:
TARGET: "{{.ITEM}}"
build-target:
internal: true
label: "build-{{.TARGET}}"
vars:
EXT: '{{if contains "windows" .TARGET}}.exe{{end}}'
LINKER_VAR: >-
{{if eq .TARGET "aarch64-unknown-linux-gnu"}}CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
{{else if eq .TARGET "x86_64-unknown-linux-musl"}}CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc
{{else if eq .TARGET "x86_64-pc-windows-gnu"}}CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
{{end}}
OUTPUT_NAME: >-
{{.TARGET | replace "x86_64-unknown-linux-gnu" "linux-x86_64"
| replace "x86_64-unknown-linux-musl" "linux-x86_64-musl"
| replace "aarch64-unknown-linux-gnu" "linux-aarch64"
| replace "x86_64-pc-windows-gnu" "windows-x86_64"}}
cmds:
- rustup target add {{.TARGET}} 2>/dev/null || true
- '{{.LINKER_VAR}} cargo build --release --target {{.TARGET}}'
- cp target/{{.TARGET}}/release/sync_server{{.EXT}} artifacts/sync-server-{{.OUTPUT_NAME}}{{.EXT}}
- echo "Built sync-server-{{.OUTPUT_NAME}}{{.EXT}}"