Compare commits
26 commits
main
...
asch/taskf
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ea7d53a49 | |||
| 0e1849061b | |||
| 2dfb8b71e5 | |||
| e3a90833ff | |||
| 7c991c3b4d | |||
| 0d7d36e971 | |||
| 951200724c | |||
| c4f992c9d6 | |||
| e103bba12c | |||
| 439c066b57 | |||
| 63867be48a | |||
| a21b1e8c03 | |||
| d13abc115d | |||
| 7438108885 | |||
| a212aba755 | |||
| 16bb5042d5 | |||
| e25306c4c1 | |||
| c7507a3e7a | |||
| f431bea1af | |||
| d91993f249 | |||
| 45505a4bf7 | |||
| 9c5882e5fb | |||
| 19022c5b5f | |||
| 2a53fd3b59 | |||
| c638ded53a | |||
| 63a2079773 |
91 changed files with 5176 additions and 8107 deletions
11
.github/workflows/check.yml
vendored
11
.github/workflows/check.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
20
.github/workflows/deploy-docs.yml
vendored
20
.github/workflows/deploy-docs.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
23
.github/workflows/e2e.yml
vendored
23
.github/workflows/e2e.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
22
.github/workflows/publish-plugin.yml
vendored
22
.github/workflows/publish-plugin.yml
vendored
|
|
@ -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:
|
||||||
|
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -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
240
CLAUDE.md
|
|
@ -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
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -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
96
Taskfile.yml
Normal 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}}"
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
5960
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 .
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, "/");
|
||||||
|
|
|
||||||
|
|
@ -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, "/");
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,5 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["dist"]
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
4168
frontend/package-lock.json
generated
4168
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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("; ")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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})`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,5 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationDir": "./dist/types"
|
"declarationDir": "./dist/types"
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["./dist"]
|
||||||
"./dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
11
rustfmt.toml
Normal 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"
|
||||||
|
|
@ -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 -
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
rm -rf sync-server/databases
|
|
||||||
rm -rf logs
|
|
||||||
107
scripts/e2e.sh
107
scripts/e2e.sh
|
|
@ -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
|
|
||||||
|
|
@ -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 -
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}`");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
25
taskfiles/database.yml
Normal 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
35
taskfiles/docs.yml
Normal 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
134
taskfiles/e2e.yml
Normal 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
41
taskfiles/frontend.yml
Normal 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
44
taskfiles/release.yml
Normal 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
73
taskfiles/rust.yml
Normal 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}}"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue